Files
trading/score-engine/main.py
T
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

3156 lines
142 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반)
- 펀더멘털 점수 (ROE, 영업이익률, 부채비율, PER/PBR) 30%
- 뉴스 감성 점수 25%
- 공시 점수 (DART) 15%
- 기술적 분석 점수 20%
- 가격 모멘텀 점수 10%
- 가치 필터: PER 0~40, 부채비율<80%, 영업이익>0, 시총>100억
- 매일 장 마감 후 자동 집계 + 텔레그램 알림
"""
import asyncio, json, os
from datetime import datetime, date, timedelta
from typing import Optional
import asyncpg, httpx, redis.asyncio as aioredis, structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
structlog.configure(processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
])
logger = structlog.get_logger()
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", "")
TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
EXAONE_MODEL = os.getenv("EXAONE_MODEL", "exaone3.5:7.8b")
pg_pool: Optional[asyncpg.Pool] = None
redis_cl: Optional[aioredis.Redis] = None
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# ── 텔레그램 알림 ──────────────────────────────────────────
async def send_telegram(msg: str):
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
async with httpx.AsyncClient() as c:
await c.post(
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"},
timeout=10)
except Exception as e:
logger.warning("telegram.err", error=str(e))
# ── DB 초기화 ─────────────────────────────────────────────
async def init_db():
async with pg_pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS stock_scores (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
score_date DATE NOT NULL,
news_positive INTEGER DEFAULT 0,
news_negative INTEGER DEFAULT 0,
news_neutral INTEGER DEFAULT 0,
news_total INTEGER DEFAULT 0,
avg_intensity FLOAT DEFAULT 0,
news_score FLOAT DEFAULT 0,
dart_positive INTEGER DEFAULT 0,
dart_negative INTEGER DEFAULT 0,
dart_score FLOAT DEFAULT 0,
price_change_pct FLOAT DEFAULT 0,
volume_ratio FLOAT DEFAULT 0,
price_score FLOAT DEFAULT 0,
technical_score FLOAT DEFAULT 0,
foreign_score FLOAT DEFAULT 0,
short_score FLOAT DEFAULT 0,
foreign_ratio FLOAT DEFAULT 0,
short_weight FLOAT DEFAULT 0,
total_score FLOAT DEFAULT 0,
recommendation VARCHAR(20) DEFAULT '관망',
top_reasons TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, score_date)
)
""")
# 기존 테이블에 technical_score 컬럼 추가 (이미 있으면 무시)
try:
await conn.execute(
"ALTER TABLE stock_scores ADD COLUMN technical_score FLOAT DEFAULT 0")
except: pass
for col in ["foreign_score FLOAT DEFAULT 0", "short_score FLOAT DEFAULT 0",
"foreign_ratio FLOAT DEFAULT 0", "short_weight FLOAT DEFAULT 0"]:
try:
await conn.execute(f"ALTER TABLE stock_scores ADD COLUMN {col}")
except: pass
# H1/H2/H5/M2: 추세·DCF·이익품질·포지션사이징 컬럼
for col in ["trend_score FLOAT DEFAULT 0",
"intrinsic_value BIGINT DEFAULT 0",
"margin_of_safety FLOAT DEFAULT 0",
"earnings_quality FLOAT DEFAULT 0",
"position_size_pct FLOAT DEFAULT 0",
"volatility_60d FLOAT DEFAULT 0",
"market_regime_adj FLOAT DEFAULT 0",
"sector VARCHAR(40) DEFAULT ''",
"magic_score FLOAT DEFAULT 0",
"f_score INTEGER DEFAULT 0",
"roc_pct FLOAT DEFAULT 0",
"earnings_yield_pct FLOAT DEFAULT 0",
"altman_z FLOAT DEFAULT 0",
"peg FLOAT DEFAULT 0",
"momentum_pct FLOAT DEFAULT 0",
"beneish_score FLOAT DEFAULT 0",
"signals JSONB DEFAULT '{}'::jsonb",
"buy_votes INTEGER DEFAULT 0",
"sell_votes INTEGER DEFAULT 0",
"gpa_pct FLOAT DEFAULT 0",
"g_score INTEGER DEFAULT 0",
"amihud_illiq FLOAT DEFAULT 0",
"market_beta FLOAT DEFAULT 0"]:
try:
await conn.execute(f"ALTER TABLE stock_scores ADD COLUMN {col}")
except: pass
await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_date ON stock_scores(score_date DESC)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_total ON stock_scores(total_score DESC)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_code ON stock_scores(stock_code)")
await conn.execute("""
CREATE TABLE IF NOT EXISTS stock_recommendations (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
recommendation VARCHAR(20) NOT NULL,
total_score FLOAT NOT NULL,
news_score FLOAT DEFAULT 0,
dart_score FLOAT DEFAULT 0,
price_score FLOAT DEFAULT 0,
technical_score FLOAT DEFAULT 0,
top_reasons TEXT DEFAULT '',
recommended_at TIMESTAMP DEFAULT NOW()
)
""")
try:
await conn.execute(
"ALTER TABLE stock_recommendations ADD COLUMN technical_score FLOAT DEFAULT 0")
except: pass
# RAG + EXAONE 심층분석 리포트 저장
await conn.execute("""
CREATE TABLE IF NOT EXISTS deep_analysis (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
analysis_date DATE NOT NULL,
recommendation VARCHAR(20) DEFAULT '중립',
conviction INTEGER DEFAULT 0,
target_price BIGINT DEFAULT 0,
stop_loss BIGINT DEFAULT 0,
thesis TEXT DEFAULT '',
report JSONB DEFAULT '{}'::jsonb,
rag_context TEXT DEFAULT '',
quant_score FLOAT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, analysis_date)
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_deep_date ON deep_analysis(analysis_date DESC)")
await conn.execute("""
CREATE TABLE IF NOT EXISTS recommendation_performance (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
recommendation VARCHAR(20) NOT NULL,
total_score FLOAT NOT NULL,
entry_price BIGINT DEFAULT 0,
price_7d BIGINT DEFAULT 0,
price_30d BIGINT DEFAULT 0,
return_7d FLOAT DEFAULT NULL,
return_30d FLOAT DEFAULT NULL,
rec_date DATE DEFAULT CURRENT_DATE,
recommended_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stock_code, rec_date)
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_perf_code ON recommendation_performance(stock_code)")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_perf_date ON recommendation_performance(rec_date DESC)")
# M5: 벤치마크 대비 알파 추적
for col in ["kospi_return_7d FLOAT DEFAULT NULL",
"kospi_return_30d FLOAT DEFAULT NULL",
"alpha_7d FLOAT DEFAULT NULL",
"alpha_30d FLOAT DEFAULT NULL"]:
try:
await conn.execute(f"ALTER TABLE recommendation_performance ADD COLUMN {col}")
except: pass
# H3: 시장 레짐 데이터 (KOSPI 200MA/VKOSPI/금리)
await conn.execute("""
CREATE TABLE IF NOT EXISTS market_regime (
dt DATE PRIMARY KEY,
kospi_close FLOAT DEFAULT 0,
kospi_ma200 FLOAT DEFAULT 0,
kospi_above_ma BOOLEAN DEFAULT FALSE,
vkospi FLOAT DEFAULT 0,
regime VARCHAR(20) DEFAULT '중립',
regime_adj FLOAT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
""")
# H4: 섹터 컬럼 (dart_corps에 추가)
try:
await conn.execute("ALTER TABLE dart_corps ADD COLUMN sector VARCHAR(40) DEFAULT ''")
except: pass
# 공식별 가중치 학습 결과 저장
await conn.execute("""
CREATE TABLE IF NOT EXISTS weight_config (
config_date DATE PRIMARY KEY,
weights JSONB NOT NULL,
period_days INTEGER DEFAULT 0,
sample_size INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
""")
logger.info("score.db.initialized")
async def get_current_price(code: str) -> int:
"""Redis → DB 순서로 현재가 조회"""
if redis_cl:
try:
c = await redis_cl.get(f"price:{code}")
if c:
return int(json.loads(c).get("price") or 0)
except: pass
async with pg_pool.acquire() as conn:
try:
row = await conn.fetchrow(
"SELECT close_price FROM stock_ohlcv WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1", code)
if row and row["close_price"]:
return int(row["close_price"])
except: pass
return 0
HEALTH_SERVICES = {
"news-collector": "http://news-collector:8787/health",
"kis-api": "http://kis-api:8585/health",
"ta-engine": "http://ta-engine:8484/health",
"dart-collector": "http://dart-collector:8888/health",
"bareunaapi": "http://bareunaapi:5757/health",
"us-market": "http://us-market:8383/health",
"graph-engine": "http://graph-engine:9090/health",
}
async def health_check_services():
"""전체 서비스 헬스체크 → 2회 연속 실패 시 텔레그램 알림 (1시간 쿨다운)"""
failed = []
async with httpx.AsyncClient(timeout=8) as client:
for name, url in HEALTH_SERVICES.items():
try:
r = await client.get(url)
if r.status_code != 200:
failed.append(name)
except:
failed.append(name)
for svc in failed:
try:
if redis_cl and await redis_cl.get(f"health_alert:{svc}"):
continue # 1시간 쿨다운 중
# 1회 실패는 fail_count 증가만 (재시작 중 오탐 방지)
fail_key = f"health_fail:{svc}"
count = int(await redis_cl.get(fail_key) or 0) + 1 if redis_cl else 1
if redis_cl:
await redis_cl.setex(fail_key, 900, str(count)) # 15분 카운터
if count < 2:
logger.warning("healthcheck.first_fail", service=svc)
continue # 첫 번째 실패는 알림 보류
except: pass
await send_telegram(
f"⚠️ <b>서비스 장애 감지</b>\n"
f"서비스: <code>{svc}</code>\n"
f"시각: {datetime.now().strftime('%m/%d %H:%M')}\n"
f"! docker logs trading-{svc} --tail 30"
)
try:
if redis_cl:
await redis_cl.setex(f"health_alert:{svc}", 3600, "1")
await redis_cl.delete(f"health_fail:{svc}")
except: pass
if failed:
logger.warning("healthcheck.failed", services=failed)
async def _kospi_return_between(conn, start_date: date, end_date: date) -> Optional[float]:
"""KOSPI 두 날짜 사이 수익률 (%) — stock_ohlcv에 'KOSPI' 코드 필요"""
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code='KOSPI' AND dt IN ($1, $2)
ORDER BY dt
""", start_date, end_date)
if len(rows) < 2:
return None
s, e = float(rows[0]["close_price"]), float(rows[1]["close_price"])
if s <= 0:
return None
return (e - s) / s * 100
async def update_performance_prices():
"""추천 7일/30일 후 수익률 + KOSPI 대비 알파"""
async with pg_pool.acquire() as conn:
rows_7d = await conn.fetch("""
SELECT id, stock_code, entry_price, rec_date FROM recommendation_performance
WHERE price_7d = 0 AND entry_price > 0
AND rec_date <= CURRENT_DATE - 7 AND rec_date >= CURRENT_DATE - 60
""")
for row in rows_7d:
price = await get_current_price(row["stock_code"])
if price > 0:
ret = (price - row["entry_price"]) / row["entry_price"] * 100
kospi_ret = await _kospi_return_between(
conn, row["rec_date"], row["rec_date"] + timedelta(days=7))
alpha = (ret - kospi_ret) if kospi_ret is not None else None
await conn.execute("""
UPDATE recommendation_performance
SET price_7d=$1, return_7d=$2, kospi_return_7d=$3, alpha_7d=$4, updated_at=NOW()
WHERE id=$5
""", price, ret, kospi_ret, alpha, row["id"])
rows_30d = await conn.fetch("""
SELECT id, stock_code, entry_price, rec_date FROM recommendation_performance
WHERE price_30d = 0 AND entry_price > 0
AND rec_date <= CURRENT_DATE - 30 AND rec_date >= CURRENT_DATE - 120
""")
for row in rows_30d:
price = await get_current_price(row["stock_code"])
if price > 0:
ret = (price - row["entry_price"]) / row["entry_price"] * 100
kospi_ret = await _kospi_return_between(
conn, row["rec_date"], row["rec_date"] + timedelta(days=30))
alpha = (ret - kospi_ret) if kospi_ret is not None else None
await conn.execute("""
UPDATE recommendation_performance
SET price_30d=$1, return_30d=$2, kospi_return_30d=$3, alpha_30d=$4, updated_at=NOW()
WHERE id=$5
""", price, ret, kospi_ret, alpha, row["id"])
logger.info("performance.updated", rows_7d=len(rows_7d), rows_30d=len(rows_30d))
def get_recommendation(score: float, buy_votes: int = 0, sell_votes: int = 0) -> str:
"""
임계값 + 다수공식 동의 강제
- 강력매수: 점수 ≥70 AND 6공식 중 ≥3 매수 동의
- 매수관심: 점수 ≥40 AND 매수≥1 AND 매도<2
- 강력매도: 점수 ≤-60
- 매도관심: 점수 ≤-30 OR 매도≥3
- 그 외: 관망
"""
if score >= 70 and buy_votes >= 3:
return "강력매수"
if score >= 40 and buy_votes >= 1 and sell_votes < 2:
return "매수관심"
if score <= -60 or sell_votes >= 4:
return "강력매도"
if score <= -30 or sell_votes >= 3:
return "매도관심"
return "관망"
def calc_fundamental_score(fin: dict, per: float, pbr: float) -> tuple[float, list[str]]:
"""
버핏 스타일 펀더멘털 점수 (-100 ~ +100)
ROE, 영업이익률, 부채비율, 매출성장률, PER/PBR 종합
returns: (score, reasons)
"""
if not fin:
return 0.0, []
score = 0.0
reasons = []
roe = fin.get("roe", 0.0)
op_margin = fin.get("operating_margin", 0.0)
debt_ratio = fin.get("debt_ratio", 100.0)
rev_growth = fin.get("revenue_growth", 0.0)
net_margin = fin.get("net_margin", 0.0)
fcf_ratio = fin.get("fcf_ratio", 0.0)
op_profit = fin.get("operating_profit", 0)
revenue = fin.get("revenue", 0)
# 영업이익 적자 → 가치투자 대상 아님 (강한 패널티)
if op_profit <= 0 or revenue <= 0:
return -50.0, ["영업적자 종목 제외"]
# ROE 점수 (버핏: ROE>15% 선호) - 30점 배분
if roe >= 20: score += 30; reasons.append(f"ROE {roe:.1f}% (우수)")
elif roe >= 15: score += 20; reasons.append(f"ROE {roe:.1f}% (양호)")
elif roe >= 10: score += 10; reasons.append(f"ROE {roe:.1f}% (보통)")
elif roe >= 5: score += 0
elif roe >= 0: score -= 10
else: score -= 30; reasons.append(f"ROE {roe:.1f}% (적자)")
# 영업이익률 점수 - 20점 배분
if op_margin >= 20: score += 20; reasons.append(f"영업이익률 {op_margin:.1f}% (우수)")
elif op_margin >= 10: score += 12
elif op_margin >= 5: score += 5
elif op_margin > 0: score += 0
else: score -= 20
# 부채비율 점수 (낮을수록 좋음) - 20점 배분
if debt_ratio <= 30: score += 20; reasons.append(f"부채비율 {debt_ratio:.0f}% (건전)")
elif debt_ratio <= 50: score += 12
elif debt_ratio <= 70: score += 5
elif debt_ratio <= 80: score += 0
else: score -= 15; reasons.append(f"부채비율 {debt_ratio:.0f}% (위험)")
# 매출 성장률 - 15점 배분
if rev_growth >= 20: score += 15; reasons.append(f"매출성장 {rev_growth:.1f}%")
elif rev_growth >= 10: score += 10
elif rev_growth >= 0: score += 3
else: score -= 10; reasons.append(f"매출감소 {rev_growth:.1f}%")
# PER 밸류에이션 - 15점 배분 (0은 데이터 없음으로 중립)
if 0 < per <= 10: score += 15; reasons.append(f"PER {per:.1f} (저평가)")
elif 0 < per <= 15: score += 10
elif 0 < per <= 25: score += 5
elif 0 < per <= 40: score += 0
elif per > 40: score -= 15; reasons.append(f"PER {per:.1f} (고평가)")
# PBR - 보조 (자산가치)
if 0 < pbr <= 1.0: score += 5; reasons.append(f"PBR {pbr:.2f} (자산저평가)")
elif 0 < pbr <= 2.0: score += 2
elif pbr > 5.0: score -= 5
# FCF - 현금창출력
fcf_ratio = fin.get("fcf_ratio", 0.0)
if fcf_ratio >= 10: score += 5; reasons.append(f"FCF {fcf_ratio:.1f}% (우수)")
elif fcf_ratio >= 5: score += 2
elif fcf_ratio < 0: score -= 5
# 배당 점수 (버핏: 꾸준한 배당 선호)
dps = fin.get("dps") or 0
dps_prev = fin.get("dps_prev") or 0
div_yield = fin.get("dividend_yield") or 0.0
if dps > 0:
score += 5
if dps > dps_prev > 0:
score += 5; reasons.append(f"배당성장 {dps:,}원→{dps_prev:,}")
elif div_yield >= 3.0:
reasons.append(f"배당수익률 {div_yield:.1f}%")
return max(-100.0, min(100.0, score)), reasons
def calc_foreign_score(foreign_data: list) -> tuple[float, str]:
"""외국인 수급 점수 (-100~+100)"""
if not foreign_data or len(foreign_data) < 2:
return 0.0, ""
score = 0.0
reason = ""
recent = foreign_data[:5]
net_changes = [r.get("change_qty", 0) for r in recent]
buy_days = sum(1 for c in net_changes if c > 0)
sell_days = sum(1 for c in net_changes if c < 0)
# 매수/매도 연속성 점수 (±40)
if buy_days >= 4:
score += 40; reason = f"외국인 {buy_days}일 순매수"
elif buy_days >= 3:
score += 20; reason = f"외국인 순매수 우세"
elif sell_days >= 4:
score -= 40; reason = f"외국인 {sell_days}일 순매도"
elif sell_days >= 3:
score -= 20
# 보유비중 변화 점수 (±40)
cur_ratio = foreign_data[0].get("hold_ratio", 0)
old_ratio = foreign_data[min(4, len(foreign_data)-1)].get("hold_ratio", cur_ratio)
delta = cur_ratio - old_ratio
if delta > 1.5: score += 40; reason = (reason or "") + f" 비중+{delta:.1f}%p"
elif delta > 0.5: score += 20
elif delta > 0.1: score += 8
elif delta < -1.5: score -= 40; reason = (reason or "") + f" 비중{delta:.1f}%p"
elif delta < -0.5: score -= 20
elif delta < -0.1: score -= 8
# 절대 보유비중 (±20) - 40% 이상 외국인 보유 = 우량주 신호
if cur_ratio >= 40: score += 20
elif cur_ratio >= 25: score += 10
elif cur_ratio <= 5: score -= 10
return max(-100.0, min(100.0, score)), reason
def calc_short_score(short_data: list) -> tuple[float, str]:
"""공매도 점수 (-100~+100), 공매도 많을수록 패널티"""
if not short_data:
return 0.0, ""
score = 0.0
reason = ""
r0 = short_data[0]
weight = r0.get("trade_weight", 0) # 공매도 거래비중 %
balance = r0.get("short_balance_qty", 0)
# 거래비중 패널티
if weight < 1.0: score += 20
elif weight < 2.0: score += 5
elif weight < 5.0: score -= 15
elif weight < 10.0: score -= 35; reason = f"공매도비중 {weight:.1f}%"
else: score -= 60; reason = f"공매도비중 {weight:.1f}% 위험"
# 잔고 추세 (5일 변화)
if len(short_data) >= 5:
past_bal = short_data[4].get("short_balance_qty", balance)
if past_bal > 0:
bal_chg_pct = (balance - past_bal) / past_bal * 100
if bal_chg_pct > 20: score -= 20; reason = (reason or "") + " 잔고급증"
elif bal_chg_pct > 5: score -= 10
elif bal_chg_pct < -20: score += 15
elif bal_chg_pct < -5: score += 7
return max(-100.0, min(100.0, score)), reason
# ── H1: 5년 재무 추세 점수 ────────────────────────────────
async def calc_trend_score(conn, stock_code: str) -> tuple[float, str]:
"""
최근 5년치 사업보고서 ROE/영업이익률의 일관성·추세 점수 (-30~+30)
버핏: 안정적이고 우상향하는 수익성 선호
"""
rows = await conn.fetch("""
SELECT bsns_year, roe, operating_margin
FROM dart_financials
WHERE stock_code=$1 AND reprt_code='11011' AND roe IS NOT NULL
ORDER BY bsns_year DESC LIMIT 5
""", stock_code)
if len(rows) < 2:
return 0.0, ""
roes = [float(r["roe"]) for r in rows]
ops = [float(r["operating_margin"]) for r in rows]
n = len(roes)
score, parts = 0.0, []
# 일관성: ROE 표준편차가 낮을수록 가산 (변동성 적음)
avg_roe = sum(roes) / n
var_roe = sum((r - avg_roe) ** 2 for r in roes) / n
std_roe = var_roe ** 0.5
if avg_roe > 5 and std_roe < 5:
score += 10; parts.append(f"ROE 일관성(σ={std_roe:.1f})")
# 추세: 최근(roes[0])이 평균보다 크면 우상향
if roes[0] > avg_roe * 1.05:
score += 10; parts.append("ROE 우상향")
elif roes[0] < avg_roe * 0.85:
score -= 10; parts.append("ROE 둔화")
# 영업이익률 5년 평균 양수면 가산
avg_op = sum(ops) / n
if avg_op >= 10:
score += 10
elif avg_op < 0:
score -= 15
return max(-30.0, min(30.0, score)), " · ".join(parts)
# ── H2: 간이 DCF 내재가치 + 안전마진 ──────────────────────
def calc_dcf(fin: dict, market_cap: int, growth: float = 0.05,
discount: float = 0.09, terminal_growth: float = 0.025) -> tuple[int, float]:
"""
버핏 스타일 간이 DCF
- 영업현금흐름(없으면 영업이익 80%) 5년 성장 후 영구가치 합산
- 시총 대비 할인율 = 안전마진
returns: (내재가치, 안전마진_pct)
"""
op_cf = fin.get("operating_cashflow", 0) or int(fin.get("operating_profit", 0) * 0.8)
if op_cf <= 0 or market_cap <= 0:
return 0, 0.0
# 5년 cash flow projection
pv = 0.0
cf = float(op_cf)
for t in range(1, 6):
cf = cf * (1 + growth)
pv += cf / ((1 + discount) ** t)
# Terminal value (Gordon growth)
cf_terminal = cf * (1 + terminal_growth)
tv = cf_terminal / (discount - terminal_growth)
pv += tv / ((1 + discount) ** 5)
intrinsic = int(pv)
# 터미널밸류(15.4배)가 대형주에서 폭주 → 내재가치를 [0, 3×시총]으로 제한.
# 마진은 이미 [-100,200] clamp이므로 스코어링 불변, 표기·RAG값만 현실화.
intrinsic = max(0, min(intrinsic, market_cap * 3))
# 안전마진 (시총 대비)
margin_pct = (intrinsic - market_cap) / market_cap * 100
return intrinsic, max(-100.0, min(200.0, margin_pct))
def calc_dcf_score(margin_pct: float) -> tuple[float, str]:
"""안전마진 → 점수 (-30~+30)"""
if margin_pct >= 50:
return 30.0, f"안전마진 {margin_pct:.0f}% (대폭저평가)"
if margin_pct >= 25:
return 20.0, f"안전마진 {margin_pct:.0f}% (저평가)"
if margin_pct >= 0:
return 10.0, f"안전마진 {margin_pct:.0f}%"
if margin_pct >= -25:
return 0.0, ""
return -15.0, f"안전마진 {margin_pct:.0f}% (고평가)"
# ── H5: 이익 품질 (영업현금흐름/영업이익) ─────────────────
def calc_earnings_quality(fin: dict) -> tuple[float, str]:
"""영업현금흐름/영업이익 비율 검증, 0.7 미만이면 분식 의심 패널티"""
op_cf = fin.get("operating_cashflow", 0)
op_pf = fin.get("operating_profit", 0)
if op_pf <= 0:
return 0.0, ""
ratio = op_cf / op_pf if op_pf else 0
if ratio >= 1.0:
return 10.0, f"이익품질 {ratio:.2f}(우수)"
if ratio >= 0.7:
return 0.0, ""
if ratio >= 0:
return -10.0, f"이익품질 {ratio:.2f}(저조)"
return -20.0, f"이익품질 {ratio:.2f}(분식의심)"
# ── 그린블라트 매직 포뮬러 (ROC + Earnings Yield) ─────────
def calc_magic_formula(fin: dict, market_cap: int) -> tuple[float, float, float, str]:
"""
한국 시장 단순화 버전 (현금 데이터 부재로 EBIT/EV 대신 시총+총부채 사용)
- ROC ≈ 영업이익 / 총자산
- EY ≈ 영업이익 / (시총 + 총부채)
returns: (score 0~30, roc_pct, ey_pct, reason)
"""
op_pf = fin.get("operating_profit", 0) or 0
ta = fin.get("total_assets", 0) or 0
tl = fin.get("total_liabilities", 0) or 0
if op_pf <= 0 or ta <= 0 or market_cap <= 0:
return 0.0, 0.0, 0.0, ""
roc = op_pf / ta * 100
ev = market_cap + tl
ey = op_pf / ev * 100 if ev > 0 else 0.0
score = 0.0
if roc >= 25: score += 15
elif roc >= 15: score += 10
elif roc >= 8: score += 5
if ey >= 15: score += 15
elif ey >= 10: score += 10
elif ey >= 6: score += 5
reason = ""
if score >= 20:
reason = f"매직포뮬러 ROC {roc:.0f}%·EY {ey:.0f}% (저평가우량)"
elif score >= 10:
reason = f"매직포뮬러 ROC {roc:.0f}%·EY {ey:.0f}%"
return score, round(roc, 2), round(ey, 2), reason
# ── 피오트로스키 F-Score (7개 신호) ───────────────────────
def calc_piotroski_score(curr: dict, prev: dict) -> tuple[int, float, str]:
"""
9신호 중 데이터 가용 7개로 0~7점 산출 (current_ratio / 신주발행 생략)
1) ROA>0 2) CFO>0 3) ΔROA>0 4) CFO>NI
5) Δdebt_ratio<0 6) Δop_margin>0 7) Δasset_turnover>0
returns: (f_score 0~7, score_adj -15~+15, reason)
"""
if not curr or not prev:
return 0, 0.0, ""
ta_c = curr.get("total_assets", 0) or 0
ta_p = prev.get("total_assets", 0) or 0
if ta_c <= 0 or ta_p <= 0:
return 0, 0.0, ""
ni_c = curr.get("net_income", 0) or 0
cfo_c = curr.get("operating_cashflow", 0) or 0
ni_p = prev.get("net_income", 0) or 0
rev_c = curr.get("revenue", 0) or 0
rev_p = prev.get("revenue", 0) or 0
roa_c = ni_c / ta_c
roa_p = ni_p / ta_p
om_c = curr.get("operating_margin", 0) or 0
om_p = prev.get("operating_margin", 0) or 0
dr_c = curr.get("debt_ratio", 0) or 0
dr_p = prev.get("debt_ratio", 0) or 0
at_c = rev_c / ta_c if ta_c else 0
at_p = rev_p / ta_p if ta_p else 0
f = 0
if roa_c > 0: f += 1
if cfo_c > 0: f += 1
if roa_c > roa_p: f += 1
if cfo_c > ni_c: f += 1
if dr_c < dr_p: f += 1
if om_c > om_p: f += 1
if at_c > at_p: f += 1
if f >= 6: adj, label = 15.0, "F-Score {0}/7 (재무건전)"
elif f == 5: adj, label = 8.0, "F-Score {0}/7"
elif f == 4: adj, label = 3.0, ""
elif f == 3: adj, label = 0.0, ""
else: adj, label = -15.0, "F-Score {0}/7 (가치함정 경고)"
reason = label.format(f) if label else ""
return f, adj, reason
# ── 알트만 Z-Score (단순화 — 운전자본·이익잉여금 데이터 부재) ─────
def calc_altman_z(fin: dict, market_cap: int) -> tuple[float, str, str]:
"""
Z'' 비제조업 모델 일부 변형 (가용 변수만 사용)
Z_simple = 6.72*(EBIT/총자산) + 1.05*(시총/총부채)
> 2.6 안전 / 1.1~2.6 회색 / <1.1 부도위험
returns: (z_score, signal '매수'|'매도'|'관망', reason)
"""
op_pf = fin.get("operating_profit", 0) or 0
ta = fin.get("total_assets", 0) or 0
tl = fin.get("total_liabilities", 0) or 0
if ta <= 0:
return 0.0, "관망", ""
a = op_pf / ta
b = (market_cap / tl) if tl > 0 else 1.0
z = 6.72 * a + 1.05 * b
if z >= 2.6:
return round(z, 2), "매수", f"Altman Z {z:.1f} (안전)"
if z >= 1.1:
return round(z, 2), "관망", ""
return round(z, 2), "매도", f"Altman Z {z:.1f} (부도위험)"
# ── PEG (린치 GARP) ───────────────────────────────────────
def calc_peg(curr: dict, prev: dict, per: float) -> tuple[float, str, str]:
"""
PEG = PER / 이익성장률(%) — 1.0 이하 저평가
이익성장률은 net_income 전년 대비
"""
if per <= 0 or not curr or not prev:
return 0.0, "관망", ""
ni_c = curr.get("net_income", 0) or 0
ni_p = prev.get("net_income", 0) or 0
if ni_p <= 0 or ni_c <= 0:
return 0.0, "관망", ""
growth = (ni_c - ni_p) / ni_p * 100
if growth <= 0:
return 0.0, "관망", ""
peg = per / growth
if peg <= 0.75:
return round(peg, 2), "매수", f"PEG {peg:.2f} (성장저평가)"
if peg <= 1.5:
return round(peg, 2), "매수", f"PEG {peg:.2f}"
if peg <= 3.0:
return round(peg, 2), "관망", ""
return round(peg, 2), "매도", f"PEG {peg:.1f} (성장 대비 고평가)"
# ── 퀄리티+모멘텀 (12-1개월 가격 모멘텀) ──────────────────
async def calc_momentum(conn, stock_code: str) -> tuple[float, str, str]:
"""
AQR 스타일 12-1개월 모멘텀: (P_t-21 / P_t-252) - 1
최근 1개월 제외(반전효과 회피)한 11개월 수익률
"""
rows = await conn.fetch("""
SELECT close_price, dt FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 260
""", stock_code)
if len(rows) < 200:
return 0.0, "관망", ""
closes = [(r["dt"], float(r["close_price"])) for r in rows if r["close_price"] > 0]
if len(closes) < 200:
return 0.0, "관망", ""
p_recent = closes[20][1] # 1개월(거래일 ~21) 전
p_year = closes[-1][1] # 약 12개월 전
if p_year <= 0:
return 0.0, "관망", ""
mom = (p_recent - p_year) / p_year * 100
if mom >= 30:
return round(mom, 1), "매수", f"모멘텀 +{mom:.0f}% (강세)"
if mom >= 10:
return round(mom, 1), "매수", f"모멘텀 +{mom:.0f}%"
if mom >= -10:
return round(mom, 1), "관망", ""
if mom >= -30:
return round(mom, 1), "매도", f"모멘텀 {mom:.0f}%"
return round(mom, 1), "매도", f"모멘텀 {mom:.0f}% (약세)"
# ── Beneish M-Score 단순화 (회계조작 의심도) ──────────────
def calc_beneish_simplified(curr: dict, prev: dict) -> tuple[float, str, str]:
"""
8변수 다 부재 → 핵심 3개 휴리스틱:
- TATA = (NI - CFO) / 총자산 (발생액/자산 — 클수록 의심)
- SGI = 매출_t / 매출_t-1 (>1.5 + 발생액 클수록 의심)
- 이익품질: CFO/NI < 0.5 → 매도 / >1.0 → 매수
"""
if not curr:
return 0.0, "관망", ""
ta = curr.get("total_assets", 0) or 0
ni = curr.get("net_income", 0) or 0
cfo = curr.get("operating_cashflow", 0) or 0
rev_c = curr.get("revenue", 0) or 0
rev_p = (prev or {}).get("revenue", 0) or 0
if ta <= 0 or ni == 0:
return 0.0, "관망", ""
tata = (ni - cfo) / ta
sgi = rev_c / rev_p if rev_p > 0 else 1.0
cfo_ni = cfo / ni if ni > 0 else 0
# 의심도 점수 (0~100, 낮을수록 좋음)
suspicion = 0.0
if tata > 0.10: suspicion += 40
elif tata > 0.05: suspicion += 20
if sgi > 1.5 and tata>0.05: suspicion += 30
if cfo_ni < 0.5 and ni>0: suspicion += 30
elif cfo_ni < 0.7: suspicion += 10
if suspicion >= 50:
return round(suspicion, 1), "매도", f"Beneish 의심도 {suspicion:.0f} (분식의심)"
if cfo_ni > 1.0 and tata < 0.03:
return round(suspicion, 1), "매수", f"Beneish 청정 (CFO/NI {cfo_ni:.2f})"
return round(suspicion, 1), "관망", ""
# ── Novy-Marx Profitability (2013): GP/A ─────────────────
def calc_gp_a(fin: dict) -> tuple[float, str, str]:
"""
Novy-Marx (2013): Gross Profit / Total Assets
데이터 부재(매출원가 없음)로 영업이익 기반 변형: 영업이익/총자산
> 15% 매수, < 0% 매도, 그 사이 관망
"""
op_pf = fin.get("operating_profit", 0) or 0
ta = fin.get("total_assets", 0) or 0
if ta <= 0: return 0.0, "관망", ""
gpa = op_pf / ta * 100
if gpa >= 15: return round(gpa, 2), "매수", f"GP/A {gpa:.1f}% (수익성 우수)"
if gpa >= 5: return round(gpa, 2), "관망", ""
if gpa >= 0: return round(gpa, 2), "관망", ""
return round(gpa, 2), "매도", f"GP/A {gpa:.1f}% (수익성 부진)"
# ── Mohanram G-Score (2005, 5신호 — R&D/CAPEX/광고 데이터 부재 생략) ─
async def calc_mohanram_g(conn, stock_code: str, sector: str, fin_curr: dict, fin_prev: dict) -> tuple[int, str, str]:
"""
Mohanram G-Score (2005): 저PB 가치 종목에서 추가 회피 신호
원본 8신호 중 R&D/CAPEX/광고 데이터 없어 5신호로 축소:
1) ROA > 섹터 중앙값
2) CFO/총자산 > 섹터 중앙값
3) CFO > NI (이익 품질)
4) ΔROA > 0
5) ROA 변동성 < 섹터 중앙값 (5년치 필요 — 부재 시 ROA > 0으로 대체)
"""
if not fin_curr or not sector or sector == "기타":
return 0, "관망", ""
ta_c = fin_curr.get("total_assets", 0) or 0
if ta_c <= 0: return 0, "관망", ""
ni_c = fin_curr.get("net_income", 0) or 0
cfo_c = fin_curr.get("operating_cashflow", 0) or 0
roa_c = ni_c / ta_c
cfoa_c = cfo_c / ta_c
# 섹터 중앙값 fetch
rows = await conn.fetch("""
SELECT f.net_income, f.operating_cashflow, f.total_assets
FROM dart_corps d
JOIN dart_financials f ON f.stock_code=d.stock_code AND f.reprt_code='11011'
WHERE d.sector=$1 AND d.is_active=true
AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2
WHERE f2.stock_code=d.stock_code AND f2.reprt_code='11011')
AND f.total_assets > 0
""", sector)
if len(rows) < 5: return 0, "관망", ""
sec_roa = sorted([(r["net_income"] or 0) / (r["total_assets"] or 1) for r in rows])
sec_cfoa = sorted([(r["operating_cashflow"] or 0) / (r["total_assets"] or 1) for r in rows])
median_roa = sec_roa[len(sec_roa)//2]
median_cfoa = sec_cfoa[len(sec_cfoa)//2]
g = 0
if roa_c > median_roa: g += 1
if cfoa_c > median_cfoa: g += 1
if cfo_c > ni_c: g += 1
if fin_prev:
ta_p = fin_prev.get("total_assets", 0) or 0
roa_p = (fin_prev.get("net_income", 0) or 0) / ta_p if ta_p else 0
if roa_c > roa_p: g += 1
if roa_c > 0: g += 1 # 5신호 변형 (변동성 대체)
if g >= 4: return g, "매수", f"G-Score {g}/5 (가치+성장 우수)"
if g <= 1: return g, "매도", f"G-Score {g}/5 (가치함정 위험)"
return g, "관망", ""
# ── Amihud 비유동성 (2002) ────────────────────────────────
async def calc_amihud(conn, stock_code: str) -> tuple[float, str, str]:
"""
Amihud (2002): ILLIQ = avg(|return| / 거래대금) × 1e9
소형주 비유동성 프리미엄 — 높을수록 알파 잠재력 ↑ but 거래 어려움
20일 평균 사용 (1년 미만 데이터에서도 작동)
"""
rows = await conn.fetch("""
SELECT close_price, volume FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 21
""", stock_code)
if len(rows) < 10: return 0.0, "관망", ""
closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0]
vols = [int(r["volume"] or 0) for r in rows]
if len(closes) < 10: return 0.0, "관망", ""
illiq_vals = []
for i in range(len(closes) - 1):
ret = abs(closes[i] - closes[i+1]) / closes[i+1] if closes[i+1] > 0 else 0
trade_amount = closes[i] * vols[i]
if trade_amount > 0:
illiq_vals.append(ret / trade_amount * 1e9)
if not illiq_vals: return 0.0, "관망", ""
illiq = sum(illiq_vals) / len(illiq_vals)
# 한국 시장 분포 기준 임계: > 100 (소형 비유동성), < 1 (대형 유동)
if illiq >= 100:
return round(illiq, 2), "매수", f"Amihud {illiq:.0f} (소형 알파 후보)"
if illiq >= 10:
return round(illiq, 2), "관망", ""
return round(illiq, 2), "관망", ""
# ── 시장 베타 (BAB 핵심 — Frazzini-Pedersen 2014) ──────────
async def calc_beta(conn, stock_code: str, days: int = 60) -> tuple[float, str, str]:
"""
종목 일별 수익률 vs KOSPI 60일 회귀 베타
BAB(Betting Against Beta) 알파: 저베타 종목이 위험조정 후 우월
β < 0.7 매수 (저베타 알파), β > 1.5 매도 (고베타 위험), 그 사이 관망
"""
rows = await conn.fetch("""
SELECT s.dt, s.close_price AS stk, k.close_price AS kospi
FROM stock_ohlcv s
JOIN stock_ohlcv k ON k.dt=s.dt AND k.stock_code='KOSPI'
WHERE s.stock_code=$1
ORDER BY s.dt DESC LIMIT $2
""", stock_code, days + 1)
if len(rows) < 30: return 0.0, "관망", ""
s_rets, k_rets = [], []
for i in range(len(rows) - 1):
s_now, s_prev = float(rows[i]["stk"]), float(rows[i+1]["stk"])
k_now, k_prev = float(rows[i]["kospi"]), float(rows[i+1]["kospi"])
if s_prev > 0 and k_prev > 0:
s_rets.append((s_now - s_prev) / s_prev)
k_rets.append((k_now - k_prev) / k_prev)
if len(s_rets) < 20: return 0.0, "관망", ""
n = len(s_rets)
s_mean = sum(s_rets) / n; k_mean = sum(k_rets) / n
cov = sum((s_rets[i] - s_mean) * (k_rets[i] - k_mean) for i in range(n)) / n
var = sum((k_rets[i] - k_mean) ** 2 for i in range(n)) / n
if var <= 0: return 0.0, "관망", ""
beta = cov / var
if beta < 0.7: return round(beta, 2), "매수", f"베타 {beta:.2f} (저베타 알파)"
if beta > 1.5: return round(beta, 2), "매도", f"베타 {beta:.2f} (고베타 위험)"
return round(beta, 2), "관망", ""
# ── 앙상블 보팅 (공식별 신호 다수결) ───────────────────────
def aggregate_signals(signals: dict) -> tuple[str, dict]:
"""
signals: {공식이름: '매수'/'매도'/'관망'}
returns: (요약문, 카운트 dict)
"""
counts = {"매수": 0, "매도": 0, "관망": 0}
for s in signals.values():
counts[s] = counts.get(s, 0) + 1
parts = []
if counts["매수"] > 0: parts.append(f"매수 {counts['매수']}")
if counts["매도"] > 0: parts.append(f"매도 {counts['매도']}")
summary = "/".join(parts) if parts else ""
return summary, counts
# ── M2: 포지션 사이징 (변동성 역가중 + 점수 가중) ─────────
async def calc_position_size(conn, stock_code: str, total_score: float) -> tuple[float, float]:
"""
추천 비중(%) = base * (50 / 변동성60d) * (점수/100)
base=10%, 최소 1%, 최대 15%
returns: (size_pct, vol_60d)
"""
if total_score < 30:
return 0.0, 0.0
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 60
""", stock_code)
if len(rows) < 30:
return 5.0, 0.0 # 기본 5%
closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0]
rets = [(closes[i] - closes[i+1]) / closes[i+1] for i in range(len(closes)-1)
if closes[i+1] > 0]
if not rets:
return 5.0, 0.0
avg = sum(rets) / len(rets)
var = sum((r - avg) ** 2 for r in rets) / len(rets)
vol = (var ** 0.5) * (252 ** 0.5) * 100 # 연환산 변동성 %
if vol < 5: vol = 5
base = 10.0
size = base * (50.0 / vol) * (total_score / 100.0)
return round(max(1.0, min(15.0, size)), 2), round(vol, 2)
# ── M4: catalyst 가중치 ───────────────────────────────────
CATALYST_WEIGHTS = {
"실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4, "기타": 1.0, "모멘텀": 0.8,
}
async def calc_news_score_weighted(conn, stock_code: str, week_ago: date) -> tuple[float, dict]:
"""catalyst별 가중치 적용된 뉴스 점수"""
rows = await conn.fetch("""
SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst
FROM news_analysis
WHERE primary_stock=$1 AND analyzed_at >= $2
AND sentiment IN ('호재','악재')
""", stock_code, datetime.combine(week_ago, datetime.min.time()))
if not rows:
return 0.0, {"pos": 0, "neg": 0, "total": 0}
score = 0.0
pos = neg = 0
for r in rows:
w = CATALYST_WEIGHTS.get(r["catalyst"], 1.0)
intensity = float(r["intensity"] or 1)
if r["sentiment"] == "호재":
score += intensity * 5 * w
pos += 1
else:
score -= intensity * 5 * w
neg += 1
return max(-100.0, min(100.0, score)), {"pos": pos, "neg": neg, "total": len(rows)}
# ── H3: KOSPI 200일 데이터 수집 (네이버 finance) ──────────
async def fetch_kospi_ohlcv() -> int:
"""네이버 차트 API에서 KOSPI 일봉 ~300일 가져와 stock_ohlcv['KOSPI']에 저장"""
import re
end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=300)).strftime("%Y%m%d")
url = (f"https://api.finance.naver.com/siseJson.naver"
f"?symbol=KOSPI&requestType=1&startTime={start}&endTime={end}&timeframe=day")
saved = 0
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
text = r.text
# 응답 형식: [[날짜,시가,고가,저가,종가,거래량,외국인소진율], ...] 단 strict JSON 아님
# 헤더 첫 줄 제거 후 각 row 파싱
body = text[text.find("[", text.find("[")+1):] # 두 번째 '[' 부터
rows = re.findall(r"\[([^\[\]]+)\]", body)
async with pg_pool.acquire() as conn:
for row in rows:
parts = [p.strip().strip("'\"") for p in row.split(",")]
if len(parts) < 6: continue
try:
dt_str = parts[0]
if len(dt_str) != 8: continue
dt_d = date(int(dt_str[:4]), int(dt_str[4:6]), int(dt_str[6:8]))
o = int(float(parts[1])); h = int(float(parts[2]))
l = int(float(parts[3])); c = int(float(parts[4]))
v = int(float(parts[5]))
await conn.execute("""
INSERT INTO stock_ohlcv (stock_code, dt, open_price, high_price, low_price, close_price, volume)
VALUES ('KOSPI', $1, $2, $3, $4, $5, $6)
ON CONFLICT (stock_code, dt) DO UPDATE SET close_price=$5
""", dt_d, o, h, l, c, v)
saved += 1
except: pass
except Exception as e:
logger.warning("kospi.fetch_err", error=str(e))
logger.info("kospi.saved", count=saved)
return saved
# ── OHLCV 백필 (네이버 일봉) — 모멘텀/BAB/기술 복구용 ──────
# 키움 ka10005는 최근 30봉·연속조회 미제공이라 252봉 모멘텀 불가.
# 네이버 siseJson은 종목당 ~300+봉 제공(검증) → 이걸로 과거 백필.
async def fetch_naver_ohlcv(conn, code: str, days: int = 400) -> int:
"""네이버 siseJson 종목 일봉 → stock_ohlcv. 반환: 저장 봉수"""
import re
end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d")
url = (f"https://api.finance.naver.com/siseJson.naver"
f"?symbol={code}&requestType=1&startTime={start}"
f"&endTime={end}&timeframe=day")
saved = 0
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
text = r.text
body = text[text.find("[", text.find("[") + 1):] # 헤더행 다음부터
for row in re.findall(r"\[([^\[\]]+)\]", body):
parts = [p.strip().strip("'\"") for p in row.split(",")]
if len(parts) < 6:
continue
dt_str = parts[0]
if len(dt_str) != 8:
continue
try:
dt_d = date(int(dt_str[:4]), int(dt_str[4:6]), int(dt_str[6:8]))
o, h = int(float(parts[1])), int(float(parts[2]))
l, c = int(float(parts[3])), int(float(parts[4]))
v = int(float(parts[5]))
except Exception:
continue
if c <= 0:
continue
await conn.execute("""
INSERT INTO stock_ohlcv (stock_code, dt, open_price,
high_price, low_price, close_price, volume)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (stock_code, dt) DO UPDATE SET
close_price=EXCLUDED.close_price, volume=EXCLUDED.volume
""", code, dt_d, o, h, l, c, v)
saved += 1
except Exception as e:
logger.debug("naver_ohlcv.err", code=code, error=str(e))
return saved
# ── H3: 시장 레짐 ─────────────────────────────────────────
async def calc_market_regime(conn) -> tuple[str, float]:
"""
KOSPI 종가 vs 200일 이평으로 시장 레짐 판단
위면 강세(+5), 아래면 약세(-10), 데이터 없으면 중립
"""
# KOSPI 인덱스를 stock_ohlcv에 코드 'KOSPI'로 저장한다고 가정
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code='KOSPI' ORDER BY dt DESC LIMIT 200
""")
if len(rows) < 100:
return "데이터부족", 0.0
closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0]
if not closes:
return "데이터부족", 0.0
cur = closes[0]
ma200 = sum(closes) / len(closes)
if cur > ma200 * 1.02:
return "강세", 5.0
if cur < ma200 * 0.95:
return "약세", -10.0
return "중립", 0.0
# ── H4: 섹터 (DART corp 분류 / KRX) ──────────────────────
async def get_stock_sector(conn, stock_code: str) -> str:
"""
1차: dart_corps.sector 컬럼 (있으면)
2차: stock_recommendations 같은 테이블 별도 매핑 (미구현 시 '기타')
"""
try:
row = await conn.fetchrow(
"SELECT sector FROM dart_corps WHERE stock_code=$1", stock_code)
if row and row["sector"]:
return row["sector"]
except: pass
return "기타"
def is_value_investable(fin: dict, per: float, pbr: float, market_cap: int) -> tuple[bool, str]:
"""버핏 기준 투자 가능 여부 필터"""
# 재무 데이터 부재 = 검증 불가 → 가치투자 대상 제외 (관망 처리)
if not fin:
return False, "재무 데이터 부재"
if fin.get("operating_profit", 0) <= 0:
return False, "영업적자"
if fin.get("revenue", 0) <= 0:
return False, "매출 없음"
if fin.get("debt_ratio", 0) > 85:
return False, f"부채비율 {fin.get('debt_ratio',0):.0f}% 초과"
# PER>60 단독 배제는 폐지(B) — 성장/테마주 구제 위해 호출부에서
# PEG·12-1모멘텀과 함께 판단 (calculate_daily_scores, 모멘텀 산출 직후)
if market_cap > 0 and market_cap < 10_000_000_000: # 100억 미만
return False, "시총 100억 미만"
return True, ""
# ── 일간 점수 산출 ────────────────────────────────────────
# ══════════════════════════════════════════════════════════
# 신규 보조 시그널 (임원매매 / 컨센서스 / 매크로 / 기관 / 밸류 percentile)
# ══════════════════════════════════════════════════════════
async def _load_insider_map(conn) -> dict:
"""최근 90일 임원·대주주 매매 집계. 종목별 (net_change, buy_cnt, sell_cnt, top_actor)"""
rows = await conn.fetch("""
SELECT stock_code,
SUM(shares_change) AS net,
SUM(CASE WHEN shares_change > 0 THEN 1 ELSE 0 END) AS buys,
SUM(CASE WHEN shares_change < 0 THEN 1 ELSE 0 END) AS sells,
COUNT(*) AS total
FROM dart_insider_trades
WHERE rcept_dt >= CURRENT_DATE - 90
GROUP BY stock_code
""")
return {r["stock_code"]: dict(r) for r in rows}
def calc_insider_signal(stat: dict) -> tuple[float, str]:
"""매수 위주면 +, 매도 위주면 -. 최대 ±15."""
if not stat:
return 0.0, ""
buys = int(stat["buys"] or 0)
sells = int(stat["sells"] or 0)
if buys + sells == 0:
return 0.0, ""
# 매수/매도 비율 기반 점수
ratio = (buys - sells) / max(1, buys + sells) # -1 ~ +1
sig = ratio * 15.0
# 거래량 가중 (절대수치) — 너무 적으면 신뢰도 ↓
if buys + sells < 3:
sig *= 0.5
sig = max(-15.0, min(15.0, sig))
reason = f"내부자 매수{buys}/매도{sells}"
return sig, reason
async def _load_consensus_map(conn) -> dict:
rows = await conn.fetch(
"SELECT stock_code, target_price, recomm_mean FROM analyst_consensus "
"WHERE updated_at >= CURRENT_DATE - 30")
return {r["stock_code"]: dict(r) for r in rows}
def calc_consensus_signal(cons: dict, cur_price: float) -> tuple[float, str]:
"""목표주가 대비 상승여력 + 매수의견 평균.
네이버 recomm_mean: 5에 가까울수록 매수, 1에 가까울수록 매도.
잠재상승률 = (target/cur - 1) * 100"""
if not cons or cur_price <= 0:
return 0.0, ""
tp = float(cons.get("target_price") or 0)
rm = float(cons.get("recomm_mean") or 0)
if tp <= 0 and rm == 0:
return 0.0, ""
sig = 0.0
reason_parts = []
if tp > 0:
upside = (tp / cur_price - 1) * 100 # %
# +30% 이상이면 +6, -10% 이하면 -6
upside_score = max(-6.0, min(6.0, upside / 5.0))
sig += upside_score
reason_parts.append(f"목표가{int(tp):,}({upside:+.0f}%)")
if rm > 0:
# 5=매수 → +4, 1=매도 → -4 (3.0이 중립 기준)
rm_score = (rm - 3.0) * 2.0
sig += max(-4.0, min(4.0, rm_score))
sig = max(-10.0, min(10.0, sig))
return sig, " ".join(reason_parts)
async def _load_macro_state(conn) -> dict:
"""최근 5일 vs 그 이전 5일 매크로 변동률"""
rows = await conn.fetch("""
SELECT indicator, trade_date, value FROM macro_daily
WHERE trade_date >= CURRENT_DATE - 20
ORDER BY indicator, trade_date DESC
""")
by_ind: dict = {}
for r in rows:
by_ind.setdefault(r["indicator"], []).append(float(r["value"]))
out = {}
for ind, vals in by_ind.items():
if len(vals) < 6:
continue
recent = sum(vals[:5]) / 5
prev = sum(vals[5:10]) / max(1, len(vals[5:10]))
chg_pct = (recent / prev - 1) * 100 if prev else 0
out[ind] = {"current": vals[0], "chg_pct": chg_pct}
return out
# 섹터 키워드 → 매크로 영향 베타
# (usdkrw_beta, kor_10y_beta): 환율 1% 상승 시 점수, 금리 1%p 상승 시 점수
SECTOR_MACRO_BETA = [
(["반도체", "전자집적", "전자부품"], 4.0, -2.0), # 수출↑환율호재, 금리↑부담
(["자동차", "운송장비"], 4.0, -1.0),
(["조선", "선박"], 4.0, -0.5),
(["철강", "1차금속"], 3.0, -0.5),
(["석유", "정제", "원유"], -3.0, 1.0), # 정유: 환율 부담, 금리↑금융수익
(["은행", "보험", "증권", "금융"], 0.0, 3.0), # 금리↑이자수익
(["소프트웨어", "정보서비스", "인터넷"], -1.0, -3.0), # 성장주: 금리↑부담
(["바이오", "생물의약", "의약품"], -1.0, -2.0),
(["건설"], -2.0, -2.0),
(["식품", "음료"], -1.5, 0.0),
]
def calc_macro_signal(sector: str, macro: dict) -> tuple[float, str]:
"""섹터 매크로 베타 × 최근 5일 매크로 변동률"""
if not macro or not sector:
return 0.0, ""
usdkrw_chg = (macro.get("usdkrw") or {}).get("chg_pct", 0)
kor_10y_chg = (macro.get("kor_10y") or {}).get("chg_pct", 0)
fx_beta, rate_beta = 0.0, 0.0
matched = False
for kws, fb, rb in SECTOR_MACRO_BETA:
if any(kw in sector for kw in kws):
fx_beta, rate_beta = fb, rb
matched = True
break
if not matched:
return 0.0, ""
sig = fx_beta * (usdkrw_chg / 1.0) + rate_beta * (kor_10y_chg / 1.0)
sig = max(-10.0, min(10.0, sig))
parts = []
if abs(fx_beta * usdkrw_chg) > 0.5:
parts.append(f"환율{usdkrw_chg:+.1f}%")
if abs(rate_beta * kor_10y_chg) > 0.5:
parts.append(f"금리{kor_10y_chg:+.1f}%")
return sig, " ".join(parts)
async def _load_inst_flow_map(conn) -> dict:
"""종목별 최근 5일 기관·외국인 순매수 합계"""
rows = await conn.fetch("""
SELECT stock_code,
SUM(inst_net) AS inst5d,
SUM(foreign_net) AS for5d,
AVG(close_price)::float AS avg_price
FROM inst_daily_flow
WHERE trade_date >= CURRENT_DATE - 7
GROUP BY stock_code
""")
return {r["stock_code"]: dict(r) for r in rows}
def calc_inst_flow_signal(flow: dict) -> tuple[float, str]:
"""기관 5일 순매수가 평균거래량 대비 의미 있으면 + 가산. 외국인과 같은 방향이면 추가 가중."""
if not flow:
return 0.0, ""
inst5 = int(flow.get("inst5d") or 0)
for5 = int(flow.get("for5d") or 0)
if inst5 == 0 and for5 == 0:
return 0.0, ""
# 기관 시그널: tanh 스케일 (포화)
import math
inst_score = math.tanh(inst5 / 5_000_000) * 6.0
# 외국인 같은 방향이면 +4, 반대면 -2
if inst5 * for5 > 0:
inst_score += 4.0 if abs(for5) > 1_000_000 else 2.0
elif inst5 * for5 < 0:
inst_score -= 2.0
sig = max(-10.0, min(10.0, inst_score))
direction = "매수" if inst5 > 0 else "매도"
reason = f"기관 5d {direction}{abs(inst5)//10000:,}만주"
return sig, reason
def calc_valuation_percentile(per_history: list, cur_per: float) -> tuple[float, str]:
"""과거 PER 분포 대비 현재 위치. 표본 부족 시 0."""
if not per_history or len(per_history) < 30 or cur_per <= 0:
return 0.0, ""
sorted_h = sorted([p for p in per_history if p > 0])
if len(sorted_h) < 30:
return 0.0, ""
# percentile rank
below = sum(1 for p in sorted_h if p < cur_per)
pct = below / len(sorted_h) * 100
# 하위 20% → +10, 하위 40% → +5, 상위 20% → -10
if pct <= 20:
return 10.0, f"PER 역사적 저평가({pct:.0f}%ile)"
if pct <= 40:
return 5.0, f"PER 저평가({pct:.0f}%ile)"
if pct >= 80:
return -10.0, f"PER 고평가({pct:.0f}%ile)"
if pct >= 60:
return -5.0, f"PER 고평가({pct:.0f}%ile)"
return 0.0, ""
async def calculate_daily_scores():
logger.info("scoring.start")
today = date.today()
week_ago = today - timedelta(days=7)
strong_buy: list = []
strong_sell: list = []
# H3: KOSPI 일봉 갱신 후 시장 레짐 계산
await fetch_kospi_ohlcv()
# 공식별 학습 가중치 로드 (없으면 균등 1.0)
formula_weights = {"magic": 1.0, "fscore": 1.0, "altman": 1.0,
"peg": 1.0, "momentum": 1.0, "beneish": 1.0,
"graph": 1.0}
async with pg_pool.acquire() as conn:
cfg = await conn.fetchrow(
"SELECT weights FROM weight_config ORDER BY config_date DESC LIMIT 1")
if cfg and cfg["weights"]:
try:
w = cfg["weights"]
if isinstance(w, str):
w = json.loads(w)
for k in formula_weights:
if k in w:
formula_weights[k] = float(w[k])
except Exception as e:
logger.warning("weights.load_err", error=str(e))
async with pg_pool.acquire() as conn:
# H3: 시장 레짐 1회 계산 (전 종목 동일 적용)
regime_label, regime_adj = await calc_market_regime(conn)
await conn.execute("""
INSERT INTO market_regime (dt, regime, regime_adj)
VALUES ($1, $2, $3)
ON CONFLICT (dt) DO UPDATE SET regime=$2, regime_adj=$3
""", today, regime_label, regime_adj)
logger.info("market.regime", label=regime_label, adj=regime_adj)
# 신규 보조 시그널 사전 로드 (한 번에 dict로)
insider_map: dict = {}
consensus_map: dict = {}
flow_map: dict = {}
macro_state: dict = {}
try:
insider_map = await _load_insider_map(conn)
consensus_map = await _load_consensus_map(conn)
flow_map = await _load_inst_flow_map(conn)
macro_state = await _load_macro_state(conn)
logger.info("aux_signals.loaded", insider=len(insider_map),
consensus=len(consensus_map), flow=len(flow_map),
macro_inds=len(macro_state))
except Exception as e:
logger.warning("aux_signals.load_err", error=str(e))
# 미국증시 overnight 보정 사전 로드 (us-market 서비스가 채움)
# 시그널 날짜는 미국장 마감 기준이라 한국 today와 1일 차이날 수 있음
# → 최근 2일 이내 가장 최신 시그널 사용
us_overnight_map: dict = {}
try:
us_rows = await conn.fetch(
"SELECT DISTINCT ON (kr_code) kr_code, total_adj, contributing_pairs, signal_date "
"FROM us_overnight_signal "
"WHERE signal_date >= $1::date - 2 "
"ORDER BY kr_code, signal_date DESC", today)
for r in us_rows:
cp = r["contributing_pairs"]
if isinstance(cp, str):
try: cp = json.loads(cp)
except: cp = {}
top_str = ""
pairs = (cp or {}).get("pairs", []) if cp else []
if pairs:
top = max(pairs, key=lambda p: abs(p.get("contribution", 0)))
top_str = (f"{top.get('us','')} "
f"({top.get('pct',0):+.1f}% × β{top.get('beta',1):.1f})")
us_overnight_map[r["kr_code"]] = {
"adj": float(r["total_adj"] or 0),
"top": top_str,
}
logger.info("us_overnight.loaded", count=len(us_overnight_map))
except Exception as e:
logger.warning("us_overnight.load_err", error=str(e))
# GNN 예측 사전 로드 (graph-engine이 매일 16:25 KST 채움)
graph_pred_map: dict = {}
try:
g_rows = await conn.fetch(
"SELECT DISTINCT ON (stock_code) stock_code, pred_return "
"FROM graph_predictions "
"WHERE predict_date >= $1::date - 3 "
"ORDER BY stock_code, predict_date DESC", today)
for r in g_rows:
graph_pred_map[r["stock_code"]] = float(r["pred_return"] or 0)
logger.info("graph_pred.loaded", count=len(graph_pred_map))
except Exception as e:
logger.warning("graph_pred.load_err", error=str(e))
# 7일 뉴스 통계 dict (stock_code → 집계) — 뉴스 없는 종목도 점수화하므로 lookup 방식
news_rows = await conn.fetch("""
SELECT primary_stock AS stock,
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,
COALESCE(AVG(intensity), 0) AS avg_int,
SUM(CASE WHEN sentiment='호재' THEN intensity ELSE 0 END) AS pos_score,
SUM(CASE WHEN sentiment='악재' THEN intensity ELSE 0 END) AS neg_score
FROM news_analysis
WHERE primary_stock != ''
AND primary_stock NOT IN ('코스피','코스닥','KOSPI','KOSDAQ','없음','')
AND analyzed_at >= $1
GROUP BY primary_stock
""", datetime.combine(week_ago, datetime.min.time()))
news_stats_map = {r["stock"]: r for r in news_rows}
# 점수 산출 대상: 활성 종목 전체 (뉴스 유무 무관)
candidate_rows = await conn.fetch("""
SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code
""")
scored = 0
for row in candidate_rows:
stock = row["stock_code"]
if not stock or len(stock) > 20: continue
# 뉴스 점수 (없으면 0)
news_row = news_stats_map.get(stock)
if news_row:
raw_news = (float(news_row["pos_score"] or 0) - float(news_row["neg_score"] or 0)) * 5
avg_int = float(news_row["avg_int"] or 0)
else:
raw_news = 0.0
avg_int = 0.0
news_score = max(-100.0, min(100.0, raw_news))
# DART 공시 점수
dart_rows = await conn.fetch("""
SELECT sentiment, intensity FROM news_analysis
WHERE source = 'DART공시' AND primary_stock = $1 AND analyzed_at >= $2
""", stock, datetime.combine(week_ago, datetime.min.time()))
dart_pos = sum(1 for r in dart_rows if r["sentiment"] == "호재")
dart_neg = sum(1 for r in dart_rows if r["sentiment"] == "악재")
dart_score = max(-100.0, min(100.0, (dart_pos - dart_neg) * 15))
# 가격/PER/PBR/시총 (Redis price:{code} → fallback: DB stock_prices)
price_score = 0.0
price_change = 0.0
has_price = False
per = pbr = market_cap = 0.0
if redis_cl:
try:
cached = await redis_cl.get(f"price:{stock}")
if cached:
pd = json.loads(cached)
price_change = pd.get("change_pct", 0)
price_score = max(-100.0, min(100.0, price_change * 10))
per = float(pd.get("per", 0) or 0)
pbr = float(pd.get("pbr", 0) or 0)
market_cap = float(pd.get("market_cap", 0) or 0)
has_price = True
except: pass
# DB fallback 1: stock_prices (장중 수집 데이터)
if not has_price:
try:
pr = await conn.fetchrow(
"SELECT change_pct, per, pbr, market_cap FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1",
stock)
if pr:
price_change = float(pr["change_pct"] or 0)
price_score = max(-100.0, min(100.0, price_change * 10))
per = float(pr["per"] or 0)
pbr = float(pr["pbr"] or 0)
market_cap = float(pr["market_cap"] or 0)
has_price = True
except: pass
# DB fallback 2: stock_ohlcv 최근 종가 (장마감 후 price:{code} TTL 만료 시)
if not has_price:
try:
ov = await conn.fetchrow(
"SELECT close_price, foreign_ratio FROM stock_ohlcv WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1",
stock)
if ov and ov["close_price"] > 0:
# 전일 종가 기반, 당일 변동 미반영 → price_score=0 (중립)
price_change = 0.0
price_score = 0.0
has_price = True # 가격 정보는 있음 (변동률만 없음)
except: pass
# 기술적 점수 (stock_technical 테이블 - Redis TTL/DB 불일치 방지)
technical_score = 0.0
try:
ta_row = await conn.fetchrow(
"SELECT tech_score FROM stock_technical WHERE stock_code=$1", stock)
if ta_row:
technical_score = float(ta_row["tech_score"] or 0)
except: pass
# 외국인 수급 점수 (Redis foreign:{code})
foreign_score = 0.0; foreign_ratio = 0.0; foreign_reason = ""
try:
f_raw = await redis_cl.get(f"foreign:{stock}")
if f_raw:
f_data = json.loads(f_raw)
foreign_score, foreign_reason = calc_foreign_score(f_data)
foreign_ratio = f_data[0].get("hold_ratio", 0) if f_data else 0
except: pass
# 공매도 점수 (Redis short:{code})
short_score = 0.0; short_weight_val = 0.0; short_reason = ""
try:
s_raw = await redis_cl.get(f"short:{stock}")
if s_raw:
s_data = json.loads(s_raw)
short_score, short_reason = calc_short_score(s_data)
short_weight_val = s_data[0].get("trade_weight", 0) if s_data else 0
except: pass
# 펀더멘털 점수 (dart_financials - 최신 사업보고서 기준)
fin_row = await conn.fetchrow("""
SELECT f.roe, f.operating_margin, f.net_margin, f.debt_ratio,
f.fcf_ratio, f.revenue_growth, f.operating_profit, f.revenue,
f.operating_cashflow, f.total_equity, f.net_income,
f.total_assets, f.total_liabilities,
d.dps, d.dps_prev, d.dividend_yield
FROM dart_financials f
LEFT JOIN dart_dividends d ON d.stock_code = f.stock_code
AND d.bsns_year = f.bsns_year
WHERE f.stock_code = $1
ORDER BY f.bsns_year DESC, f.reprt_code DESC
LIMIT 1
""", stock)
fin_data = dict(fin_row) if fin_row else {}
# LEFT JOIN으로 NULL 가능 → 숫자 컬럼 일괄 정규화 (None → 0)
for k in ("roe", "operating_margin", "net_margin", "debt_ratio",
"fcf_ratio", "revenue_growth", "operating_profit", "revenue",
"operating_cashflow", "total_equity", "net_income",
"total_assets", "total_liabilities",
"dps", "dps_prev", "dividend_yield"):
if fin_data.get(k) is None:
fin_data[k] = 0
# 최근 2년 연간 사업보고서 (F-Score year-over-year + 매직 포뮬러용)
# 분기 보고서가 아닌 연간(11011)을 써야 영업이익/총자산 단위가 일치
f_score_rows = await conn.fetch("""
SELECT bsns_year, operating_margin, debt_ratio, revenue,
operating_cashflow, net_income, operating_profit,
total_assets, total_liabilities
FROM dart_financials
WHERE stock_code=$1 AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 2
""", stock)
fin_curr = dict(f_score_rows[0]) if len(f_score_rows) >= 1 else {}
fin_prev = dict(f_score_rows[1]) if len(f_score_rows) >= 2 else {}
# 버핏 가치 필터 적용
investable, filter_reason = is_value_investable(fin_data, per, pbr, int(market_cap))
if not investable:
logger.debug("scoring.filtered", stock=stock, reason=filter_reason)
continue
fundamental_score, fin_reasons = calc_fundamental_score(fin_data, per, pbr)
# H1: 5년 추세 점수
trend_score, trend_reason = await calc_trend_score(conn, stock)
if trend_reason:
fin_reasons.append(trend_reason)
# H2: DCF 내재가치 + 안전마진
intrinsic, mos = calc_dcf(fin_data, int(market_cap))
mos_score, mos_reason = calc_dcf_score(mos)
if mos_reason:
fin_reasons.append(mos_reason)
# H5: 이익 품질
eq_score, eq_reason = calc_earnings_quality(fin_data)
if eq_reason:
fin_reasons.append(eq_reason)
# 매직 포뮬러 (ROC + Earnings Yield) — 연간 사업보고서 기준
magic_score, roc_pct, ey_pct, magic_reason = calc_magic_formula(
fin_curr or fin_data, int(market_cap))
if magic_reason:
fin_reasons.append(magic_reason)
# 피오트로스키 F-Score (가치함정 회피)
f_score, f_score_adj, f_score_reason = calc_piotroski_score(fin_curr, fin_prev)
if f_score_reason:
fin_reasons.append(f_score_reason)
# 알트만 Z-Score (부도 위험)
altman_z, altman_sig, altman_reason = calc_altman_z(fin_curr or fin_data, int(market_cap))
if altman_reason:
fin_reasons.append(altman_reason)
# PEG (린치 GARP)
peg_val, peg_sig, peg_reason = calc_peg(fin_curr, fin_prev, per)
if peg_reason:
fin_reasons.append(peg_reason)
# 12-1개월 모멘텀 (AQR)
mom_val, mom_sig, mom_reason = await calc_momentum(conn, stock)
if mom_reason:
fin_reasons.append(mom_reason)
# B: 가치 게이트 완화 — PER>60 단독 배제 폐지.
# 흑자·재무건전(is_value_investable 통과) 종목이 PER만 높을 때
# PEG≤1.5(이익성장이 배수 정당화) 또는 12-1모멘텀≥10%(추세)면 추천 유지,
# 둘 다 아니면 '성장 미검증 고평가'로 제외(가치함정 회피).
if 0 < per > 60:
peg_ok = 0 < (peg_val or 0) <= 1.5
mom_ok = (mom_val or 0) >= 10.0
if not (peg_ok or mom_ok):
logger.debug("scoring.per_gate", stock=stock,
per=round(per, 1), peg=peg_val, mom=mom_val)
continue
fin_reasons.append(
f"PER{per:.0f} 고평가나 "
+ (f"PEG{peg_val:.2f}" if peg_ok else f"모멘텀+{mom_val:.0f}%")
+ " 성장 구제")
# Beneish M-Score 단순화 (분식 의심)
beneish_val, beneish_sig, beneish_reason = calc_beneish_simplified(fin_curr, fin_prev)
if beneish_reason:
fin_reasons.append(beneish_reason)
# H4: 섹터 (G-Score보다 먼저 정의 필요)
sector = await get_stock_sector(conn, stock)
# Novy-Marx GP/A (2013) — 수익성
gpa_val, gpa_sig, gpa_reason = calc_gp_a(fin_curr or fin_data)
if gpa_reason:
fin_reasons.append(gpa_reason)
# Mohanram G-Score (2005) — 가치+성장 회피
g_val, g_sig, g_reason = await calc_mohanram_g(conn, stock, sector, fin_curr, fin_prev)
if g_reason:
fin_reasons.append(g_reason)
# Amihud 비유동성 (2002) — 소형 알파
amihud_val, amihud_sig, amihud_reason = await calc_amihud(conn, stock)
if amihud_reason:
fin_reasons.append(amihud_reason)
# 시장 베타 (BAB — Frazzini-Pedersen 2014)
beta_val, beta_sig, beta_reason = await calc_beta(conn, stock)
if beta_reason:
fin_reasons.append(beta_reason)
# 매직/F-Score → 신호 변환 (점수 기반)
magic_sig = "매수" if magic_score >= 20 else ("관망" if magic_score >= 5 else "관망")
f_sig = "매수" if f_score >= 6 else ("매도" if 0 < f_score <= 2 else "관망")
# GNN 신호 (graph-engine 예측: 다음날 수익률 %)
graph_pred = graph_pred_map.get(stock, 0.0)
if graph_pred >= 0.3: graph_sig = "매수"
elif graph_pred <= -0.3: graph_sig = "매도"
else: graph_sig = "관망"
graph_score_val = max(-30, min(30, graph_pred * 30)) # ±30 가산점
# 앙상블 보팅 (11개 공식: 학술 알파 10 + GNN)
sig_map = {
"magic": magic_sig,
"fscore": f_sig,
"altman": altman_sig,
"peg": peg_sig,
"momentum": mom_sig,
"beneish": beneish_sig,
"gpa": gpa_sig,
"gscore": g_sig,
"amihud": amihud_sig,
"beta": beta_sig,
"graph": graph_sig,
}
ensemble_summary, vote_counts = aggregate_signals(sig_map)
if ensemble_summary:
fin_reasons.append(f"공식보팅 [{ensemble_summary}]")
# M4: catalyst 가중 뉴스점수로 교체 (위에서 계산한 raw_news 대체)
news_score_w, news_stats = await calc_news_score_weighted(conn, stock, week_ago)
news_score = news_score_w
# 펀더멘털 통합: 기존 + 추세 + 이익품질 + 매직포뮬러 + F-Score (DCF는 종합점수에 별도 가중)
fundamental_combined = max(-100.0, min(100.0,
fundamental_score + trend_score + eq_score + magic_score + f_score_adj))
# 종합 점수 (가중치 재배분)
# 펀더24% + 뉴스18% + 기술15% + 공시10% + 외국인14% + 공매도6% + 가격3% + 안전마진10%
total = (fundamental_combined * 0.24 + news_score * 0.18
+ technical_score * 0.15 + dart_score * 0.10
+ foreign_score * 0.14 + short_score * 0.06
+ price_score * 0.03 + mos_score * 0.10)
# 앙상블 보팅 가산점: 학습 가중치 적용 (max ±18, 균등 시 6공식 합 = 18)
ensemble_bonus = 0.0
for fname, fsig in sig_map.items():
w = formula_weights.get(fname, 1.0)
if fsig == "매수": ensemble_bonus += w * 3.0
elif fsig == "매도": ensemble_bonus -= w * 3.0
ensemble_bonus = max(-18.0, min(18.0, ensemble_bonus))
# 미국증시 overnight 보정 (sector_adj + pair_adj, max ±15)
us_info = us_overnight_map.get(stock, {"adj": 0.0, "top": ""})
us_adj = float(us_info["adj"])
# 신규 5개 보조 시그널 (각각 ±10~15)
insider_sig, insider_reason = calc_insider_signal(insider_map.get(stock))
consensus_sig, consensus_reason = calc_consensus_signal(
consensus_map.get(stock), float(price_change and market_cap and 0) or
(await conn.fetchval(
"SELECT price FROM stock_prices WHERE stock_code=$1 "
"ORDER BY collected_at DESC LIMIT 1", stock) or 0))
macro_sig, macro_reason = calc_macro_signal(sector, macro_state)
flow_sig, flow_reason = calc_inst_flow_signal(flow_map.get(stock))
# 밸류 percentile: stock_prices에 누적된 PER 시계열 사용
per_hist = await conn.fetch(
"SELECT per FROM stock_prices WHERE stock_code=$1 AND per > 0 "
"ORDER BY collected_at DESC LIMIT 200", stock)
per_list = [float(r["per"]) for r in per_hist]
val_sig, val_reason = calc_valuation_percentile(per_list, per)
aux_total = insider_sig + consensus_sig + macro_sig + flow_sig + val_sig
# H3: 시장 레짐 + 앙상블 + 미국증시 + 5개 보조
total = max(-100, min(100,
total + regime_adj + ensemble_bonus + us_adj + aux_total))
rec = get_recommendation(total, vote_counts["매수"], vote_counts["매도"])
# 보조 시그널 근거 (점수에 영향 큰 것만)
for sval, rtxt in [(insider_sig, insider_reason),
(consensus_sig, consensus_reason),
(macro_sig, macro_reason),
(flow_sig, flow_reason),
(val_sig, val_reason)]:
if abs(sval) >= 3.0 and rtxt:
fin_reasons.append(f"{rtxt}({sval:+.0f})")
# M2: 포지션 사이징
pos_size, vol_60d = await calc_position_size(conn, stock, total)
# 주요 근거 (뉴스 + 펀더멘털)
news_reasons = await conn.fetch("""
SELECT reason FROM news_analysis
WHERE primary_stock = $1 AND analyzed_at >= $2
AND sentiment IN ('호재','악재') AND intensity >= 2
ORDER BY intensity DESC LIMIT 2
""", stock, datetime.combine(week_ago, datetime.min.time()))
extra = []
if foreign_reason: extra.append(foreign_reason)
if short_reason: extra.append(short_reason)
all_reasons = [r["reason"][:80] for r in news_reasons] + fin_reasons[:2] + extra
top_reasons = " | ".join(all_reasons)
name = await conn.fetchval(
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", stock)
name = name or stock
await conn.execute("""
INSERT INTO stock_scores (
stock_code, stock_name, score_date,
news_positive, news_negative, news_neutral, news_total,
avg_intensity, news_score,
dart_positive, dart_negative, dart_score,
price_change_pct, price_score,
technical_score, foreign_score, short_score,
foreign_ratio, short_weight,
total_score, recommendation, top_reasons,
trend_score, intrinsic_value, margin_of_safety,
earnings_quality, position_size_pct, volatility_60d,
market_regime_adj, sector,
magic_score, f_score, roc_pct, earnings_yield_pct,
altman_z, peg, momentum_pct, beneish_score,
signals, buy_votes, sell_votes,
gpa_pct, g_score, amihud_illiq, market_beta)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,
$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,
$35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45)
ON CONFLICT (stock_code, score_date) DO UPDATE SET
news_score=$9, dart_score=$12, price_score=$14,
technical_score=$15, foreign_score=$16, short_score=$17,
foreign_ratio=$18, short_weight=$19,
total_score=$20, recommendation=$21, top_reasons=$22,
trend_score=$23, intrinsic_value=$24, margin_of_safety=$25,
earnings_quality=$26, position_size_pct=$27, volatility_60d=$28,
market_regime_adj=$29, sector=$30,
magic_score=$31, f_score=$32, roc_pct=$33, earnings_yield_pct=$34,
altman_z=$35, peg=$36, momentum_pct=$37, beneish_score=$38,
signals=$39, buy_votes=$40, sell_votes=$41,
gpa_pct=$42, g_score=$43, amihud_illiq=$44, market_beta=$45
""", stock, name, today,
news_stats["pos"], news_stats["neg"], 0, news_stats["total"],
avg_int, news_score,
dart_pos, dart_neg, dart_score, price_change, price_score,
technical_score, foreign_score, short_score,
foreign_ratio, short_weight_val,
total, rec, top_reasons,
trend_score, intrinsic, mos,
eq_score, pos_size, vol_60d,
regime_adj, sector,
magic_score, f_score, roc_pct, ey_pct,
altman_z, peg_val, mom_val, beneish_val,
json.dumps(sig_map, ensure_ascii=False), vote_counts["매수"], vote_counts["매도"],
gpa_val, g_val, amihud_val, beta_val)
# 미국증시 overnight 보정값 별도 컬럼 저장
if us_info["adj"] or us_info["top"]:
await conn.execute(
"UPDATE stock_scores SET us_overnight_adj=$1, us_pair_top=$2 "
"WHERE stock_code=$3 AND score_date=$4",
us_adj, us_info["top"], stock, today)
# 5개 보조 시그널 별도 컬럼 저장
if any([insider_sig, consensus_sig, macro_sig, flow_sig, val_sig]):
await conn.execute("""
UPDATE stock_scores SET
insider_signal=$1, consensus_signal=$2,
macro_signal=$3, inst_flow_signal=$4, valuation_pct=$5
WHERE stock_code=$6 AND score_date=$7
""", insider_sig, consensus_sig, macro_sig, flow_sig, val_sig,
stock, today)
# GNN 그래프 점수 별도 컬럼 (graph-engine 예측 기반 ±30 가산)
if stock in graph_pred_map:
await conn.execute(
"UPDATE stock_scores SET graph_score=$1 "
"WHERE stock_code=$2 AND score_date=$3",
graph_score_val, stock, today)
scored += 1
# 섹터 분산 강등: 동일 섹터 매수추천이 SECTOR_MAX_BUY 초과면 '관망'으로 강등.
# sector가 대부분 비면 전 종목이 '기타'로 묶여 전 시장이 4종목으로 붕괴 →
# 섹터 채움률 50% 미만이면 강등 스킵 (sector 백필되면 자동 재활성화).
SECTOR_MAX_BUY = 4
sec_fill = await conn.fetchval("""
SELECT COALESCE(
COUNT(*) FILTER (WHERE sector IS NOT NULL AND sector <> '')::float
/ NULLIF(COUNT(*), 0), 0)
FROM dart_corps WHERE is_active=true
""")
if sec_fill < 0.5:
logger.warning("scoring.sector_demote_skipped",
sector_fill=round(float(sec_fill), 3))
else:
await conn.execute("""
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(sector,''), '기타')
ORDER BY total_score DESC) AS rn
FROM stock_scores
WHERE score_date = $1
AND recommendation IN ('강력매수', '매수관심')
)
UPDATE stock_scores
SET recommendation = '관망',
top_reasons = COALESCE(top_reasons, '') || ' | 섹터집중 강등'
FROM ranked
WHERE stock_scores.id = ranked.id AND ranked.rn > $2
""", today, SECTOR_MAX_BUY)
# 섹터 분산 후, 매수/매도 추천 일괄 INSERT (stock_recommendations + performance)
rec_rows = await conn.fetch("""
SELECT stock_code, stock_name, recommendation, total_score, sector,
news_score, dart_score, price_score, technical_score, top_reasons
FROM stock_scores
WHERE score_date = $1
AND recommendation IN ('강력매수', '매수관심', '매도관심', '강력매도')
""", today)
for r in rec_rows:
await conn.execute("""
INSERT INTO stock_recommendations (
stock_code, stock_name, recommendation, total_score,
news_score, dart_score, price_score, technical_score, top_reasons)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
""", 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"])
entry_price = 0
if redis_cl:
try:
p_raw = await redis_cl.get(f"price:{r['stock_code']}")
if p_raw:
entry_price = int(json.loads(p_raw).get("price") or 0)
except: pass
if entry_price > 0:
await conn.execute("""
INSERT INTO recommendation_performance (
stock_code, stock_name, recommendation, total_score, entry_price, rec_date)
VALUES ($1,$2,$3,$4,$5,CURRENT_DATE)
ON CONFLICT (stock_code, rec_date) DO NOTHING
""", r["stock_code"], r["stock_name"], r["recommendation"], r["total_score"], entry_price)
if r["recommendation"] == "강력매수":
strong_buy.append((r["stock_name"], r["stock_code"], r["total_score"],
r["technical_score"], 0.0, 0.0, 0.0, 0.0, 0.0))
elif r["recommendation"] == "강력매도":
strong_sell.append((r["stock_name"], r["stock_code"], r["total_score"],
r["technical_score"], 0.0, 0.0, 0.0))
logger.info("scoring.done", scored=scored, recommended=len(rec_rows))
# 텔레그램 알림
if strong_buy or strong_sell:
lines = [f"<b>📊 Trading AI 일간 리포트 ({today})</b>\n"]
if strong_buy:
lines.append("🟢 <b>강력매수 추천 (버핏 가치필터 통과)</b>")
for name, code, score, ts, fs, per, pbr, fg, sw in strong_buy[:5]:
per_str = f"PER {per:.1f}" if per > 0 else "PER -"
pbr_str = f"PBR {pbr:.2f}" if pbr > 0 else "PBR -"
fg_str = f"외국인{fg:+.0f}" if fg != 0 else ""
sw_str = f"공매도{sw:.1f}%" if sw > 0 else ""
extra_str = " | ".join(filter(None, [fg_str, sw_str]))
suffix = f" | {extra_str}" if extra_str else ""
lines.append(f"{name}({code}) 종합{score:.1f} | 펀더{fs:.0f} | {per_str} {pbr_str}{suffix}")
if strong_sell:
lines.append("\n🔴 <b>강력매도 추천</b>")
for name, code, score, ts, fs, per, pbr in strong_sell[:5]:
lines.append(f"{name}({code}) 종합{score:.1f} / 기술{ts:.1f}")
await send_telegram("\n".join(lines))
return scored
# ── 정기 브리핑 ───────────────────────────────────────────
async def send_briefing():
"""정기 시황 브리핑 — 종목당 1카드(매매가/포지션/재무/근거 통합)"""
now = datetime.now()
ta_redis = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True)
def _fmt_price(p):
try: return f"{int(p):,}" if p else "-"
except: return "-"
def _pct(v):
try: return f"{'+' if float(v)>=0 else ''}{float(v):.1f}%"
except: return "-"
def _clean_reasons_tg(raw: str, n: int = 2, ml: int = 50) -> list:
if not raw: return []
parts = [p.strip() for p in raw.split("|") if p.strip()]
seen, out = set(), []
for p in parts:
k = p[:25]
if k in seen: continue
seen.add(k)
out.append(p[:ml] + ("" if len(p) > ml else ""))
if len(out) >= n: break
return out
try:
async with pg_pool.acquire() as conn:
regime_row = await conn.fetchrow(
"SELECT regime, regime_adj FROM market_regime ORDER BY dt DESC LIMIT 1")
buy_rows = await conn.fetch("""
SELECT s.stock_name, s.stock_code, s.total_score, s.technical_score,
s.recommendation, s.trend_score, s.margin_of_safety,
s.position_size_pct, s.sector, s.top_reasons,
f.roe, f.debt_ratio
FROM stock_scores s
LEFT JOIN dart_financials f
ON f.stock_code=s.stock_code AND f.reprt_code='11011'
AND f.bsns_year = (SELECT MAX(bsns_year) FROM dart_financials f2
WHERE f2.stock_code=s.stock_code AND f2.reprt_code='11011')
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
AND s.recommendation IN ('강력매수', '매수관심')
ORDER BY s.total_score DESC LIMIT 5
""")
sell_rows = await conn.fetch("""
SELECT stock_name, stock_code, total_score, recommendation, top_reasons
FROM stock_scores
WHERE score_date = (SELECT MAX(score_date) FROM stock_scores)
AND recommendation IN ('강력매도', '매도관심')
ORDER BY total_score ASC LIMIT 3
""")
async def _get_price_ta(code: str):
price, chg, tg = None, 0.0, {}
if redis_cl:
try:
p = await redis_cl.get(f"price:{code}")
if p:
pd = json.loads(p)
price = int(pd.get("price") or 0) or None
chg = float(pd.get("change_pct", 0) or 0)
except: pass
try:
ta_raw = await ta_redis.get(f"ta:{code}")
if ta_raw:
ta = json.loads(ta_raw)
tg = ta.get("targets", {}) or {}
except: pass
return price, chg, tg
head = f"📊 <b>Trading AI 브리핑 ({now.strftime('%m/%d %H:%M')})</b>"
regime_str = (f"\n시장: <b>{regime_row['regime']}</b> "
f"({_pct(regime_row['regime_adj'])})") if regime_row else ""
lines = [head + regime_str, ""]
if buy_rows:
lines.append("🟢 <b>매수 추천</b>")
for i, row in enumerate(buy_rows, 1):
code = row["stock_code"]
icon = "🔥" if row["recommendation"] == "강력매수" else ""
price, chg, tg = await _get_price_ta(code)
sector = row["sector"] or "기타"
roe = row["roe"]; debt = row["debt_ratio"]
fin_str = ""
if roe is not None and debt is not None:
fin_str = f"ROE {roe:.1f}% · 부채 {debt:.0f}%"
lines.append(
f"\n<b>{i}. {icon} {row['stock_name']}</b> "
f"<code>{code}</code> · <i>{sector}</i>")
price_line = f"{_fmt_price(price)} ({_pct(chg)})" if price else "가격 갱신 대기"
lines.append(f"💰 {price_line} · 점수 <b>{row['total_score']:.1f}</b>")
if tg.get("t1"):
pos = row["position_size_pct"] or 0
pos_str = f" · 비중 <b>{pos:.1f}%</b>" if pos else ""
lines.append(
f"🎯 진입 {_fmt_price(tg.get('entry_price'))}"
f"T1 {_fmt_price(tg.get('t1'))} ({_pct(tg.get('t1_pct'))}) "
f"50%매도{pos_str}")
lines.append(
f" T2 {_fmt_price(tg.get('t2'))} 30% / "
f"T3 {_fmt_price(tg.get('t3'))} 20%")
if tg.get("trailing_stop"):
lines.append(
f"🛑 손절 {_fmt_price(tg.get('stop_loss'))} · "
f"Trailing {_fmt_price(tg.get('trailing_stop'))}")
if fin_str:
extras = []
mos = row["margin_of_safety"]
if mos and mos > 25:
extras.append(f"안전마진 {mos:.0f}%")
if extras:
lines.append(f"📈 {fin_str} · {' · '.join(extras)}")
else:
lines.append(f"📈 {fin_str}")
for r in _clean_reasons_tg(row["top_reasons"]):
lines.append(f"{r}")
if sell_rows:
lines.append("\n🔴 <b>회피</b>")
for row in sell_rows:
code = row["stock_code"]
icon = "" if row["recommendation"] == "강력매도" else "⚠️"
price, chg, _ = await _get_price_ta(code)
price_str = f"{_fmt_price(price)} ({_pct(chg)})" if price else "-"
lines.append(f" {icon} <b>{row['stock_name']}</b> "
f"<code>{code}</code> · {price_str} · 점수 {row['total_score']:.1f}")
for r in _clean_reasons_tg(row["top_reasons"], n=1):
lines.append(f"{r}")
if not buy_rows and not sell_rows:
lines.append("\n분석 중입니다. 장 마감 후 갱신.")
await send_telegram("\n".join(lines))
logger.info("briefing.sent", time=now.strftime("%H:%M"))
except Exception as e:
logger.error("briefing.err", error=str(e))
finally:
await ta_redis.aclose()
# ── 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=3, decode_responses=True)
await init_db()
scheduler.add_job(calculate_daily_scores, "cron",
day_of_week="mon-fri", hour=16, minute=30,
id="daily_score", replace_existing=True)
for hr, mn in [(8, 0), (12, 0), (16, 0), (18, 0)]:
scheduler.add_job(send_briefing, "cron",
day_of_week="mon-fri", hour=hr, minute=mn,
id=f"briefing_{hr}_{mn}", replace_existing=True)
# 데이터 정리: 매일 새벽 4시
scheduler.add_job(cleanup_old_data, "cron",
hour=4, minute=0,
id="cleanup", replace_existing=True)
# 성과 추적: 매일 18시 가격 업데이트
scheduler.add_job(update_performance_prices, "cron",
day_of_week="mon-fri", hour=18, minute=0,
id="perf_update", replace_existing=True)
# 헬스체크: 10분마다
scheduler.add_job(health_check_services, "interval",
minutes=10, id="health_check", replace_existing=True)
# 자동 학습: 매주 일요일
# 04:00 — 공식 가중치 학습 (90일 백테스트)
# 05:00 — 예상가 모델 학습 (선형회귀 + RF + XGBoost)
# 두 함수 모두 표본 부족 시 graceful (return early) — 데이터 누적되면 자동 활성화
scheduler.add_job(lambda: learn_weights(days=90), "cron",
day_of_week="sun", hour=4, minute=0,
id="learn_weights", replace_existing=True)
scheduler.add_job(lambda: learn_pricing(days=90), "cron",
day_of_week="sun", hour=5, minute=0,
id="learn_pricing", replace_existing=True)
# AI 심층분석: 평일 17:00 (16:30 스코어링 직후 당일 추천종목 대상)
scheduler.add_job(deep_analysis_batch_job, "cron",
day_of_week="mon-fri", hour=17, minute=0,
id="deep_batch", replace_existing=True)
scheduler.start()
# 평일 17:00 deep_batch가 컨테이너 재배포/다운으로 17:00에 떠있지 않으면
# APScheduler MemoryJobStore는 놓친 실행을 catch-up하지 않음 → startup 시 1회 보정.
async def _deep_batch_catchup():
now = datetime.now() # 컨테이너 TZ=Asia/Seoul
if now.weekday() >= 5 or now.hour < 17: # 주말 or 17:00 이전이면 정시 발화에 맡김
return
async with pg_pool.acquire() as conn:
done = await conn.fetchval(
"SELECT 1 FROM deep_analysis WHERE analysis_date=CURRENT_DATE LIMIT 1")
if done: # 오늘치 이미 있으면 스킵
return
logger.info("deep_batch.catchup_start")
await deep_analysis_batch_job()
asyncio.create_task(_deep_batch_catchup())
logger.info("score-engine.started")
@app.on_event("shutdown")
async def shutdown():
scheduler.shutdown()
if pg_pool: await pg_pool.close()
async def cleanup_old_data():
"""오래된 데이터 정리 - DB 비대화 방지"""
async with pg_pool.acquire() as conn:
# stock_prices: 30일 이상 된 것 삭제
deleted_prices = await conn.fetchval(
"WITH d AS (DELETE FROM stock_prices WHERE collected_at < NOW() - INTERVAL '30 days' RETURNING 1) SELECT COUNT(*) FROM d")
# stock_recommendations: 60일 이상 된 것 삭제
deleted_recs = await conn.fetchval(
"WITH d AS (DELETE FROM stock_recommendations WHERE recommended_at < NOW() - INTERVAL '60 days' RETURNING 1) SELECT COUNT(*) FROM d")
# news_analysis: 90일 이상 된 것 삭제 (오래된 뉴스는 분석 불필요)
deleted_news = await conn.fetchval(
"WITH d AS (DELETE FROM news_analysis WHERE analyzed_at < NOW() - INTERVAL '90 days' RETURNING 1) SELECT COUNT(*) FROM d")
# trade_signals: 30일 이상 된 것 삭제
deleted_signals = await conn.fetchval(
"WITH d AS (DELETE FROM trade_signals WHERE created_at < NOW() - INTERVAL '30 days' RETURNING 1) SELECT COUNT(*) FROM d")
logger.info("cleanup.done",
prices=deleted_prices, recs=deleted_recs,
news=deleted_news, signals=deleted_signals)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/score/calculate")
async def manual_calc():
n = await calculate_daily_scores()
return {"status": "done", "scored": n}
@app.post("/ohlcv/backfill")
async def ohlcv_backfill(count: int = Query(default=0, ge=0),
days: int = Query(default=400, ge=30, le=1200)):
"""활성종목 OHLCV 네이버 백필 (momentum/BAB/기술 복구). count=0=전체.
대량·장시간이라 백그라운드 실행 → 즉시 반환."""
async with pg_pool.acquire() as conn:
q = ("SELECT stock_code FROM dart_corps WHERE is_active=true "
"ORDER BY stock_code") + ("" if count <= 0 else f" LIMIT {count}")
codes = [r["stock_code"] for r in await conn.fetch(q)]
sem = asyncio.Semaphore(5) # pg_pool max_size·네이버 예의 고려
tot = {"codes": len(codes), "ok": 0, "bars": 0}
async def one(cd: str):
async with sem:
async with pg_pool.acquire() as conn:
n = await fetch_naver_ohlcv(conn, cd, days)
if n:
tot["ok"] += 1
tot["bars"] += n
await asyncio.sleep(0.05)
async def run():
await asyncio.gather(*[one(c) for c in codes])
logger.info("ohlcv_backfill.done", **tot)
asyncio.create_task(run())
return {"status": "started", "codes": len(codes), "days": days}
@app.post("/briefing/send")
async def manual_briefing():
await send_briefing()
return {"status": "sent"}
@app.get("/ranking")
async def ranking(date: str = Query(default=""), limit: int = Query(default=30)):
d = date or str(datetime.now().date())
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT stock_code, stock_name, total_score, news_score, dart_score,
price_score, technical_score, recommendation, top_reasons,
news_total, news_positive, news_negative
FROM stock_scores WHERE score_date=$1
ORDER BY total_score DESC LIMIT $2
""", datetime.strptime(d, "%Y-%m-%d").date(), limit)
return [dict(r) for r in rows]
@app.get("/recommendations")
async def recommendations(days: int = Query(default=7)):
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT stock_code, stock_name, recommendation, total_score,
news_score, dart_score, price_score, technical_score,
top_reasons, recommended_at
FROM stock_recommendations
WHERE recommended_at >= NOW() - INTERVAL '%s days'
ORDER BY total_score DESC LIMIT 30
""" % days)
return [dict(r) for r in rows]
@app.get("/performance/summary")
async def performance_summary():
"""추천 성과 요약 (7일/30일 수익률, 승률)"""
async with pg_pool.acquire() as conn:
stats = await conn.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 conn.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("/stock/{code}")
async def stock_detail(code: str):
async with pg_pool.acquire() as conn:
scores = await conn.fetch(
"SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 30", code)
news = await conn.fetch("""
SELECT title, sentiment, intensity, reason, investment_action, source, analyzed_at
FROM news_analysis WHERE primary_stock=$1 OR stock_codes::text LIKE $2
ORDER BY analyzed_at DESC LIMIT 20
""", code, f'%{code}%')
fin = await conn.fetch(
"SELECT * FROM dart_financials WHERE stock_code=$1 ORDER BY bsns_year DESC", code)
price = None
ta = None
if redis_cl:
try:
c = await redis_cl.get(f"price:{code}")
if c: price = json.loads(c)
t = await redis_cl.get(f"ta:{code}")
if t: ta = json.loads(t)
except: pass
return {
"code": code,
"scores": [dict(r) for r in scores],
"news": [dict(r) for r in news],
"financials": [dict(r) for r in fin],
"price": price,
"technical": ta,
}
@app.get("/backtest")
async def backtest(days: int = Query(default=180, ge=30, le=365)):
"""
M1: 과거 추천 종목의 7d/30d 수익률, KOSPI 대비 알파, 적중률, 샤프, MDD 산출
"""
since = date.today() - timedelta(days=days)
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT recommendation, total_score, return_7d, return_30d,
alpha_7d, alpha_30d, kospi_return_7d, kospi_return_30d, rec_date
FROM recommendation_performance
WHERE rec_date >= $1
""", since)
if not rows:
return {"period_days": days, "n": 0, "msg": "데이터 없음"}
def _summary(returns: list, alphas: list) -> dict:
if not returns:
return {"n": 0}
n = len(returns)
avg_ret = sum(returns) / n
sd = (sum((r - avg_ret) ** 2 for r in returns) / n) ** 0.5 if n > 1 else 0
win = sum(1 for r in returns if r > 0) / n * 100
# 일간 변동성 가정 안 하고 단순 샤프 근사 (mean/sd, RFR=0)
sharpe = avg_ret / sd if sd > 0 else 0
mdd = min(returns)
avg_alpha = sum(alphas) / len(alphas) if alphas else None
return {
"n": n,
"avg_return_pct": round(avg_ret, 2),
"win_rate_pct": round(win, 1),
"stdev": round(sd, 2),
"sharpe": round(sharpe, 2),
"max_drawdown_pct": round(mdd, 2),
"avg_alpha_pct": round(avg_alpha, 2) if avg_alpha is not None else None,
}
overall = {}
for window in ("7d", "30d"):
rs = [float(r[f"return_{window}"]) for r in rows if r[f"return_{window}"] is not None]
als = [float(r[f"alpha_{window}"]) for r in rows if r[f"alpha_{window}"] is not None]
overall[window] = _summary(rs, als)
by_rec = {}
for rec in ("강력매수", "매수관심"):
rs7 = [float(r["return_7d"]) for r in rows
if r["recommendation"] == rec and r["return_7d"] is not None]
als7 = [float(r["alpha_7d"]) for r in rows
if r["recommendation"] == rec and r["alpha_7d"] is not None]
by_rec[rec] = _summary(rs7, als7)
return {
"period_days": days,
"total_recommendations": len(rows),
"overall": overall,
"by_recommendation_7d": by_rec,
}
@app.post("/learn-weights")
async def learn_weights(days: int = Query(default=90, ge=14, le=365)):
"""
백테스트 기반 공식별 가중치 학습.
각 공식이 '매수' 신호를 낸 종목들의 평균 7일 수익률 - '매도' 신호 종목 평균 = edge
edge가 큰 공식일수록 가중치 ↑ → ensemble 보팅에 반영
"""
since = date.today() - timedelta(days=days)
formulas = ["magic", "fscore", "altman", "peg", "momentum", "beneish"]
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT s.signals, p.return_7d, p.return_30d
FROM stock_scores s
JOIN recommendation_performance p
ON s.stock_code = p.stock_code AND s.score_date = p.rec_date
WHERE p.rec_date >= $1 AND p.return_7d IS NOT NULL
""", since)
if not rows:
return {"period_days": days, "sample": 0,
"msg": "백테스트 표본 부족 — 추천·성과 데이터 누적 후 재학습",
"weights": {f: 1.0 for f in formulas}}
out = {}
for f in formulas:
buy_rets, sell_rets = [], []
for r in rows:
sigs = r["signals"] or {}
if isinstance(sigs, str):
try: sigs = json.loads(sigs)
except: sigs = {}
sig = sigs.get(f, "관망")
if sig == "매수":
buy_rets.append(float(r["return_7d"]))
elif sig == "매도":
sell_rets.append(float(r["return_7d"]))
avg_buy = sum(buy_rets)/len(buy_rets) if buy_rets else 0.0
avg_sell = sum(sell_rets)/len(sell_rets) if sell_rets else 0.0
out[f] = {
"buy_n": len(buy_rets), "buy_avg_return_7d": round(avg_buy, 2),
"sell_n": len(sell_rets), "sell_avg_return_7d": round(avg_sell, 2),
"edge": round(avg_buy - avg_sell, 2),
}
# edge 양수만 가중치 부여, 합 6 (균등) 으로 정규화
edges = {f: max(0.0, out[f]["edge"]) for f in formulas}
total_edge = sum(edges.values())
if total_edge > 0:
weights = {f: round(edges[f] / total_edge * len(formulas), 3) for f in formulas}
else:
weights = {f: 1.0 for f in formulas}
await conn.execute("""
INSERT INTO weight_config (config_date, weights, period_days, sample_size)
VALUES (CURRENT_DATE, $1, $2, $3)
ON CONFLICT (config_date) DO UPDATE
SET weights=$1, period_days=$2, sample_size=$3
""", json.dumps(weights), days, len(rows))
return {"period_days": days, "sample": len(rows),
"by_formula": out, "weights": weights,
"applied": "다음 /score/calculate 부터 자동 적용"}
@app.get("/learn-weights")
async def get_weights():
"""현재 적용 중인 공식별 학습 가중치"""
async with pg_pool.acquire() as conn:
cfg = await conn.fetchrow("""
SELECT config_date, weights, period_days, sample_size, created_at
FROM weight_config ORDER BY config_date DESC LIMIT 1
""")
if not cfg:
return {"status": "default",
"weights": {f: 1.0 for f in ["magic","fscore","altman","peg","momentum","beneish"]}}
w = cfg["weights"]
if isinstance(w, str):
try: w = json.loads(w)
except: w = {}
return {"status": "learned", "config_date": str(cfg["config_date"]),
"period_days": cfg["period_days"], "sample_size": cfg["sample_size"],
"weights": w}
ECOS_API_KEY = os.getenv("ECOS_API_KEY", "")
ECOS_INDICATORS = {
# 한국은행 ECOS 통계표 코드 (실제 코드 키 발급 후 ecos.bok.or.kr에서 조회)
"GDP_growth": ("200Y001", "10101"), # 실질GDP 전년동기비
"CPI": ("901Y009", "0"), # 소비자물가지수
"Unemployment":("901Y027", "I61BC"), # 실업률
"Base_rate": ("722Y001", "0101000"), # 한국은행 기준금리
"ConsumerSentiment": ("511Y002", "FME"), # 소비자심리지수
}
@app.get("/api/risk/{code}")
async def risk_var(code: str, days: int = Query(default=60), confidence: float = Query(default=0.95)):
"""
Historical VaR 95%: 60일 일별 수익률 분포의 5% 분위수
종목별 1일 / 5일 / 30일 VaR 계산 (단순 sqrt(t) 스케일)
"""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT $2
""", code, days + 1)
if len(rows) < 30:
return {"code": code, "msg": f"데이터 부족 ({len(rows)}일)"}
closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0]
rets = [(closes[i] - closes[i+1]) / closes[i+1] for i in range(len(closes)-1) if closes[i+1] > 0]
if not rets: return {"code": code, "msg": "수익률 계산 실패"}
rets_sorted = sorted(rets)
pct_5 = rets_sorted[int(len(rets_sorted) * (1 - confidence))]
pct_1 = rets_sorted[int(len(rets_sorted) * 0.01)]
avg = sum(rets) / len(rets)
var_d = (sum((r - avg) ** 2 for r in rets) / len(rets)) ** 0.5
return {
"code": code, "n_days": len(rets),
"var_95_1d_pct": round(pct_5 * 100, 2),
"var_99_1d_pct": round(pct_1 * 100, 2),
"var_95_5d_pct": round(pct_5 * (5 ** 0.5) * 100, 2),
"var_95_30d_pct": round(pct_5 * (30 ** 0.5) * 100, 2),
"daily_volatility_pct": round(var_d * 100, 2),
"annual_volatility_pct": round(var_d * (252 ** 0.5) * 100, 2),
"interpretation":
f"95% 신뢰수준에서 1일 최대 {abs(pct_5*100):.2f}% 손실, "
f"30일 최대 {abs(pct_5*(30**0.5)*100):.1f}% 손실 가능"
}
@app.get("/api/garch-vol/{code}")
async def garch_vol(code: str, horizon: int = Query(default=5)):
"""GARCH(1,1) 변동성 예측 — 다음 N일 평균 변동성"""
try:
from arch import arch_model
import numpy as np
except Exception as e:
return {"code": code, "err": f"arch 라이브러리 미설치: {e}"}
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 252
""", code)
if len(rows) < 100:
return {"code": code, "msg": f"데이터 부족 ({len(rows)}일, 최소 100)"}
closes = np.array([float(r["close_price"]) for r in rows[::-1] if r["close_price"] > 0])
rets = np.diff(closes) / closes[:-1] * 100 # 백분율 수익률
try:
am = arch_model(rets, mean='Zero', vol='GARCH', p=1, q=1, dist='normal')
res = am.fit(disp='off')
forecast = res.forecast(horizon=horizon)
vols = forecast.variance.iloc[-1].values ** 0.5
cur_vol = float(vols[0])
avg_vol = float(vols.mean())
# 역사적 변동성 비교
hist_vol = float(rets.std())
return {
"code": code, "n_days": len(rets),
"garch_next_day_vol_pct": round(cur_vol, 3),
f"garch_avg_{horizon}d_vol_pct": round(avg_vol, 3),
"hist_vol_pct": round(hist_vol, 3),
"vol_ratio_garch_vs_hist": round(cur_vol / hist_vol, 2) if hist_vol else 0,
"interpretation": (
"변동성 확장 국면 (GARCH > 역사 평균)" if cur_vol > hist_vol * 1.1
else "변동성 축소 국면" if cur_vol < hist_vol * 0.9
else "안정 국면"
),
}
except Exception as e:
return {"code": code, "err": f"GARCH 적합 실패: {e}"}
@app.get("/api/macro-kr")
async def macro_kr():
"""한국은행 ECOS 매크로 지표 (ECOS_API_KEY 환경변수 필요)
키 발급: https://ecos.bok.or.kr/api 무료 가입 후 신청
"""
if not ECOS_API_KEY:
return {"status": "no_key",
"msg": "ECOS_API_KEY 미설정. https://ecos.bok.or.kr/api 에서 키 발급 후 .env에 등록",
"indicators": list(ECOS_INDICATORS.keys())}
out = {}
end = datetime.now().strftime("%Y%m")
start = (datetime.now() - timedelta(days=60)).strftime("%Y%m")
async with httpx.AsyncClient(timeout=10) as c:
for name, (stat_code, item_code) in ECOS_INDICATORS.items():
try:
url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ECOS_API_KEY}/json/kr/1/3/"
f"{stat_code}/M/{start}/{end}/{item_code}")
r = await c.get(url)
if r.status_code != 200: continue
d = r.json()
rows = d.get("StatisticSearch", {}).get("row", [])
if rows:
latest = rows[-1]
out[name] = {"value": latest.get("DATA_VALUE"),
"time": latest.get("TIME"),
"unit": latest.get("UNIT_NAME")}
except Exception as e:
out[name] = {"err": str(e)}
return {"status": "ok", "data": out, "ts": datetime.now().isoformat()}
@app.post("/learn-pricing")
async def learn_pricing(days: int = Query(default=90, ge=14, le=365)):
"""
D + E: 백테스트 데이터로 두 모델 학습
- D: 단순 선형회귀 (점수 → 30일 수익률 계수)
- E: Random Forest (다변수 입력 → 30일 수익률)
표본 부족 시 graceful (default 모델 또는 None)
"""
since = date.today() - timedelta(days=days)
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT s.total_score, s.magic_score, s.f_score, s.altman_z,
s.peg, s.momentum_pct, s.beneish_score,
p.return_30d, p.return_7d
FROM stock_scores s
JOIN recommendation_performance p
ON s.stock_code=p.stock_code AND s.score_date=p.rec_date
WHERE p.rec_date >= $1
""", since)
out = {"period_days": days, "sample": len(rows)}
if len(rows) < 10:
out["msg"] = f"표본 {len(rows)} 부족 (최소 10) — 추천·성과 누적 후 재학습"
out["linear_coef"] = None
out["rf_feature_importance"] = None
return out
try:
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
except Exception as e:
return {**out, "err": f"sklearn import 실패: {e}"}
# D. 단순 선형회귀: total_score → return_30d (Walk-forward 적용)
valid_30d = [r for r in rows if r["return_30d"] is not None]
linear_summary = None
if len(valid_30d) >= 10:
X = np.array([[float(r["total_score"])] for r in valid_30d])
y = np.array([float(r["return_30d"]) for r in valid_30d])
m = LinearRegression().fit(X, y)
pred = m.predict(X)
# Walk-forward: 70/30 시간순 split (look-ahead bias 회피)
split = int(len(valid_30d) * 0.7)
oos_r2 = None
if split >= 5 and len(valid_30d) - split >= 3:
m_train = LinearRegression().fit(X[:split], y[:split])
y_test_pred = m_train.predict(X[split:])
oos_r2 = round(r2_score(y[split:], y_test_pred), 3)
linear_summary = {
"coef": round(float(m.coef_[0]), 4),
"intercept": round(float(m.intercept_), 4),
"r2_in_sample": round(r2_score(y, pred), 3),
"r2_out_of_sample_walkforward": oos_r2,
"n": len(valid_30d),
"interpretation":
f"점수 1점 상승 ≈ 30일 수익률 {m.coef_[0]:+.3f}%p, "
f"in-sample R²={r2_score(y, pred):.2f}, "
f"OOS R²={oos_r2 if oos_r2 is not None else 'N/A'} (look-ahead bias 회피)"
}
# E. Random Forest + XGBoost: 다변수 → return_30d
rf_summary = None
if len(valid_30d) >= 20:
feature_names = ["total_score", "magic_score", "f_score", "altman_z",
"peg", "momentum_pct", "beneish_score"]
X = np.array([[float(r[fn] or 0) for fn in feature_names] for r in valid_30d])
y = np.array([float(r["return_30d"]) for r in valid_30d])
rf = RandomForestRegressor(n_estimators=80, max_depth=5, random_state=42).fit(X, y)
importance = dict(zip(feature_names, [round(float(v), 3) for v in rf.feature_importances_]))
pred = rf.predict(X)
rf_summary = {
"n": len(valid_30d),
"r2_train": round(r2_score(y, pred), 3),
"feature_importance": dict(sorted(importance.items(), key=lambda x: -x[1])),
}
# XGBoost (gradient boosting) — RF보다 일반적으로 우월
try:
import xgboost as xgb
xgb_model = xgb.XGBRegressor(n_estimators=100, max_depth=4,
learning_rate=0.05, random_state=42,
objective='reg:squarederror').fit(X, y)
xgb_pred = xgb_model.predict(X)
xgb_imp = dict(zip(feature_names, [round(float(v), 3)
for v in xgb_model.feature_importances_]))
rf_summary["xgb_r2"] = round(r2_score(y, xgb_pred), 3)
rf_summary["xgb_feature_importance"] = dict(sorted(xgb_imp.items(), key=lambda x: -x[1]))
except Exception as ex:
rf_summary["xgb_err"] = str(ex)
# 학습된 모델 직렬화 → DB 저장 (간단 버전: feature_importance만 JSONB로)
async with pg_pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS pricing_model (
model_date DATE PRIMARY KEY,
linear_coef FLOAT, linear_intercept FLOAT, linear_r2 FLOAT,
rf_features JSONB, rf_r2 FLOAT, sample_size INTEGER,
period_days INTEGER, created_at TIMESTAMP DEFAULT NOW()
)
""")
await conn.execute("""
INSERT INTO pricing_model (model_date, linear_coef, linear_intercept,
linear_r2, rf_features, rf_r2, sample_size, period_days)
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (model_date) DO UPDATE SET
linear_coef=$1, linear_intercept=$2, linear_r2=$3,
rf_features=$4, rf_r2=$5, sample_size=$6, period_days=$7
""", linear_summary["coef"] if linear_summary else None,
linear_summary["intercept"] if linear_summary else None,
linear_summary["r2_in_sample"] if linear_summary else None,
json.dumps(importance), rf_summary["r2_train"],
len(valid_30d), days)
return {**out, "linear": linear_summary, "rf": rf_summary,
"applied": "다음 /predict-price 호출부터 적용"}
@app.get("/predict-price/{code}")
async def predict_price(code: str):
"""학습된 모델로 N일 후 예상 수익률·가격 추정"""
async with pg_pool.acquire() as conn:
s = await conn.fetchrow("""
SELECT total_score, magic_score, f_score, altman_z,
peg, momentum_pct, beneish_score, sector
FROM stock_scores WHERE stock_code=$1
ORDER BY score_date DESC LIMIT 1
""", code)
m = await conn.fetchrow("""
SELECT linear_coef, linear_intercept, linear_r2, rf_r2, sample_size
FROM pricing_model ORDER BY model_date DESC LIMIT 1
""")
# 현재가 — Redis or stock_prices
cur_price = 0
if redis_cl:
try:
p = await redis_cl.get(f"price:{code}")
if p:
cur_price = int(json.loads(p).get("price") or 0)
except: pass
if not cur_price:
pr = await conn.fetchrow(
"SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1",
code)
if pr: cur_price = int(pr["price"] or 0)
if not s:
return {"code": code, "msg": "stock_scores 데이터 없음"}
if not m:
return {"code": code, "msg": "학습 모델 없음 — 먼저 /learn-pricing 호출"}
pred_30d_pct = (m["linear_intercept"] or 0) + (m["linear_coef"] or 0) * float(s["total_score"])
pred_price = int(cur_price * (1 + pred_30d_pct / 100)) if cur_price else None
return {
"code": code,
"current_price": cur_price,
"current_score": float(s["total_score"]),
"predicted_30d_return_pct": round(pred_30d_pct, 2),
"predicted_30d_price": pred_price,
"model": {"r2": m["linear_r2"], "n": m["sample_size"]},
"disclaimer": "선형 회귀 기반 단순 추정. 신뢰도 R² 참고. RF 모델은 feature 기반 더 정교 (내부)",
}
@app.get("/sector/concentration")
async def sector_concentration():
"""H4: 현재 강력매수/매수관심 종목의 섹터 분포 (집중도 경고)"""
today = date.today()
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT sector, COUNT(*) AS n, AVG(total_score)::float AS avg_score
FROM stock_scores
WHERE score_date=$1 AND recommendation IN ('강력매수','매수관심')
GROUP BY sector ORDER BY n DESC
""", today)
total = sum(r["n"] for r in rows) or 1
out = []
warnings = []
for r in rows:
pct = r["n"] / total * 100
out.append({
"sector": r["sector"] or "(미분류)",
"count": r["n"],
"share_pct": round(pct, 1),
"avg_score": round(r["avg_score"], 1),
})
if pct >= 30 and r["sector"]:
warnings.append(f"{r['sector']} 섹터 집중 {pct:.0f}% (>30%)")
return {"total": total, "sectors": out, "warnings": warnings}
# ── RAG + EXAONE 종목 심층분석 ─────────────────────────────
# 정량 점수·재무추세·기술적·뉴스흐름·앙상블 보팅을 RAG 컨텍스트로 모아
# EXAONE에 버핏 관점 매수/매도 판단을 받는다. (catalyst enum 미신뢰 →
# 뉴스는 sentiment/intensity/reason 원문으로만 컨텍스트 구성)
_DEEP_RECS = {"강력매수", "매수", "중립", "매도", "강력매도"}
def _jload(v):
if isinstance(v, (dict, list)):
return v
try:
return json.loads(v) if v else {}
except Exception:
return {}
async def _build_rag_context(conn, code: str) -> tuple[str, dict]:
"""종목 RAG 컨텍스트 문자열 + 메타(name/quant_score/quant_rec/targets) 반환"""
meta = {"name": code, "quant_score": 0.0, "quant_rec": "-", "targets": {}}
ctx: list[str] = []
corp = await conn.fetchrow(
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code)
sec = await get_stock_sector(conn, code)
sec_str = sec if sec and sec != "기타" else "미분류"
sc = await conn.fetchrow(
"SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1", code)
if sc:
meta["name"] = sc["stock_name"] or (corp and corp["corp_name"]) or code
meta["quant_score"] = float(sc["total_score"] or 0)
meta["quant_rec"] = sc["recommendation"] or "-"
elif corp:
meta["name"] = corp["corp_name"]
ctx.append(f"· 종목: {meta['name']}({code}) / 섹터: {sec_str}")
if sc:
ctx.append(
f"· 퀀트 종합점수 {meta['quant_score']:.1f} → 시스템판정 [{meta['quant_rec']}] "
f"(매수보팅 {sc['buy_votes']} / 매도보팅 {sc['sell_votes']})")
ctx.append(
f"· 세부점수: 뉴스 {sc['news_score']:.0f} 공시 {sc['dart_score']:.0f} "
f"기술 {sc['technical_score']:.0f} 외국인 {sc['foreign_score']:.0f} "
f"공매도 {sc['short_score']:.0f} 추세 {sc['trend_score']:.0f} "
f"이익품질 {sc['earnings_quality']:.0f}")
ctx.append(
f"· 학술공식: 매직 {sc['magic_score']:.0f} F-Score {sc['f_score']} "
f"알트만Z {sc['altman_z']:.2f} PEG {sc['peg']:.2f} "
f"모멘텀 {sc['momentum_pct']:.1f}% Beneish {sc['beneish_score']:.0f} "
f"GP/A {sc['gpa_pct']:.1f}% G-Score {sc['g_score']} 베타 {sc['market_beta']:.2f}")
iv = int(sc["intrinsic_value"] or 0)
if iv > 0:
ctx.append(f"· DCF 내재가치 {iv:,}원 / 안전마진 {sc['margin_of_safety']:.0f}%")
sig = _jload(sc["signals"])
if sig:
sig_str = " ".join(f"{k}:{v}" for k, v in sig.items())
ctx.append(f"· 공식별 신호: {sig_str}")
fins = await conn.fetch("""
SELECT bsns_year, revenue, operating_profit, net_income,
roe, operating_margin, debt_ratio, fcf_ratio, revenue_growth
FROM dart_financials
WHERE stock_code=$1 AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 4
""", code)
if fins:
ctx.append("· 재무추세(연간 사업보고서):")
for f in fins:
ctx.append(
f" - {f['bsns_year']}: 매출 {(f['revenue'] or 0)/1e8:,.0f}"
f"영업이익 {(f['operating_profit'] or 0)/1e8:,.0f}"
f"ROE {f['roe'] or 0:.1f}% 영업이익률 {f['operating_margin'] or 0:.1f}% "
f"부채비율 {f['debt_ratio'] or 0:.0f}% FCF {f['fcf_ratio'] or 0:.1f}% "
f"매출성장 {f['revenue_growth'] or 0:.1f}%")
else:
ctx.append("· 재무: DART 연간 재무데이터 없음 (가치판단 제약)")
ta = None
if redis_cl:
try:
t = await redis_cl.get(f"ta:{code}")
if t:
ta = json.loads(t)
except Exception:
ta = None
if not ta:
trow = await conn.fetchrow("""
SELECT price, ma20, ma60, rsi, macd, macd_signal, signal,
tech_score, targets
FROM stock_technical WHERE stock_code=$1
""", code)
if trow:
ta = dict(trow)
if ta:
ctx.append(
f"· 기술적: 현재가 {ta.get('price') or 0:,}"
f"MA20 {ta.get('ma20') or 0:,.0f} MA60 {ta.get('ma60') or 0:,.0f} "
f"RSI {ta.get('rsi') or 0:.0f} 기술신호 [{ta.get('signal') or '-'}] "
f"기술점수 {ta.get('tech_score') or 0:.0f}")
tg = _jload(ta.get("targets"))
if tg and (tg.get("t1") or tg.get("stop_loss")):
meta["targets"] = tg
ctx.append(
f"· 기술적 목표가(참고): 진입 {tg.get('entry_price') or 0:,}"
f"T1 {tg.get('t1') or 0:,}원 T2 {tg.get('t2') or 0:,}"
f"T3 {tg.get('t3') or 0:,}원 손절 {tg.get('stop_loss') or 0:,}")
agg = await conn.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE sentiment='호재' AND analyzed_at>=NOW()-INTERVAL '14 days') p14,
COUNT(*) FILTER (WHERE sentiment='악재' AND analyzed_at>=NOW()-INTERVAL '14 days') n14,
COUNT(*) FILTER (WHERE sentiment='호재' AND analyzed_at>=NOW()-INTERVAL '30 days') p30,
COUNT(*) FILTER (WHERE sentiment='악재' AND analyzed_at>=NOW()-INTERVAL '30 days') n30
FROM news_analysis
WHERE primary_stock=$1 AND reason != '파싱실패'
""", code)
if agg and (agg["p30"] or agg["n30"]):
ctx.append(
f"· 뉴스 흐름: 최근14일 호재 {agg['p14']}/악재 {agg['n14']}, "
f"30일 호재 {agg['p30']}/악재 {agg['n30']}")
news = await conn.fetch("""
SELECT analyzed_at::date d, sentiment, intensity, left(reason,90) reason
FROM news_analysis
WHERE primary_stock=$1
AND reason != '파싱실패' AND sentiment IN ('호재','악재')
AND analyzed_at >= NOW()-INTERVAL '30 days'
ORDER BY analyzed_at DESC LIMIT 10
""", code)
if news:
ctx.append("· 최근 뉴스 분석:")
for r in news:
ctx.append(f" - {r['d']} {r['sentiment']}/강도{r['intensity']} {r['reason']}")
regime, _ = await calc_market_regime(conn)
ctx.append(f"· 시장 레짐: {regime}")
return "\n".join(ctx), meta
_DEEP_SYSTEM = (
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
"제공된 정량 데이터(퀀트 종합점수·학술공식 신호·재무추세·기술적·뉴스흐름)를 "
"종합해 매수/매도를 판단합니다.\n"
"판단 우선순위: 기업 본질가치(ROE·영업이익률·FCF·부채안정성·이익품질) > "
"밸류에이션(PER·PBR·DCF안전마진) > 뉴스 catalyst·모멘텀 > 단기 수급.\n"
"주어진 데이터에 근거해서만 판단하고 데이터에 없는 사실을 지어내지 마세요.\n"
"퀀트 시스템판정과 다른 결론을 내릴 경우 그 이유를 thesis에 명확히 쓰세요.\n"
"반드시 아래 스키마의 유효한 JSON 객체 하나만 출력하세요. 마크다운·설명문 금지."
)
_DEEP_SCHEMA = (
'{"recommendation":"강력매수|매수|중립|매도|강력매도",'
'"conviction":1~5 정수,'
'"thesis":"핵심 투자논거 2~3문장",'
'"key_points":["근거1","근거2","근거3"],'
'"risks":["리스크1","리스크2"],'
'"catalyst_watch":["향후 관전포인트1"],'
'"valuation_view":"저평가|적정|고평가",'
'"time_horizon":"단기|중기|장기",'
'"target_price":목표가_정수원(밸류에이션 근거 없으면 위 기술적 목표가 T2),'
'"stop_loss":손절가_정수원(근거 없으면 위 기술적 손절)}'
)
async def _exaone_json(client, system: str, user: str, max_tokens: int = 900) -> dict:
try:
r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
"model": EXAONE_MODEL,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
"max_tokens": max_tokens, "temperature": 0.1}, timeout=180)
c = r.json()["choices"][0]["message"]["content"]
c = c.replace("```json", "").replace("```", "").strip()
if not c.startswith("{"): # 모델이 JSON 앞뒤에 설명을 붙인 경우
s, e = c.find("{"), c.rfind("}")
if s != -1 and e > s:
c = c[s:e + 1]
return json.loads(c)
except Exception as e:
logger.warning("deep.exaone_err", error=str(e))
return {}
def _norm_int(v) -> int:
try:
return int(float(v))
except Exception:
return 0
async def run_deep_analysis(conn, client, code: str, save: bool = True) -> dict:
ctx, meta = await _build_rag_context(conn, code)
if "재무: DART 연간 재무데이터 없음" in ctx and meta["quant_score"] == 0.0:
return {"code": code, "error": "데이터 부족 (재무·퀀트 모두 없음)"}
user = (f"[분석 대상]\n{ctx}\n\n"
f"위 데이터를 종합해 버핏 가치투자 관점에서 매수/매도를 판단하세요.\n"
f"JSON 스키마:\n{_DEEP_SCHEMA}")
a = await _exaone_json(client, _DEEP_SYSTEM, user)
rec = a.get("recommendation", "중립")
if rec not in _DEEP_RECS:
rec = "중립"
conv = max(0, min(5, _norm_int(a.get("conviction", 0))))
tp, sl = _norm_int(a.get("target_price", 0)), _norm_int(a.get("stop_loss", 0))
tg = meta.get("targets") or {} # LLM이 0 내면 ta-engine 목표가로 폴백
if tp <= 0:
tp = _norm_int(tg.get("t2") or tg.get("t1") or 0)
if sl <= 0:
sl = _norm_int(tg.get("stop_loss") or 0)
thesis = str(a.get("thesis", "") or "")[:1000]
parsed_ok = bool(a)
def _clean(lst):
return [str(x).lstrip("-*•· ").strip()
for x in (lst or []) if str(x).strip()][:6]
report = {
"recommendation": rec, "conviction": conv,
"thesis": thesis,
"key_points": _clean(a.get("key_points")),
"risks": _clean(a.get("risks")),
"catalyst_watch": _clean(a.get("catalyst_watch")),
"valuation_view": a.get("valuation_view", "-"),
"time_horizon": a.get("time_horizon", "-"),
"target_price": tp, "stop_loss": sl,
"parsed_ok": parsed_ok,
}
if save and parsed_ok:
await conn.execute("""
INSERT INTO deep_analysis (
stock_code, stock_name, analysis_date, recommendation, conviction,
target_price, stop_loss, thesis, report, rag_context, quant_score)
VALUES ($1,$2,CURRENT_DATE,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (stock_code, analysis_date) DO UPDATE SET
recommendation=EXCLUDED.recommendation, conviction=EXCLUDED.conviction,
target_price=EXCLUDED.target_price, stop_loss=EXCLUDED.stop_loss,
thesis=EXCLUDED.thesis, report=EXCLUDED.report,
rag_context=EXCLUDED.rag_context, quant_score=EXCLUDED.quant_score,
created_at=NOW()
""", code, meta["name"], rec, conv, tp, sl, thesis,
json.dumps(report, ensure_ascii=False), ctx, meta["quant_score"])
return {
"code": code, "name": meta["name"],
"quant_score": meta["quant_score"], "quant_rec": meta["quant_rec"],
**report,
}
def _fmt_deep(r: dict) -> str:
if r.get("error"):
return f"⚠️ {r['code']}: {r['error']}"
icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "",
"매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "")
lines = [
f"{icon} <b>{r['name']}({r['code']})</b> — AI심층분석",
f"판단: <b>{r['recommendation']}</b> (확신도 {r['conviction']}/5) "
f"· 퀀트 {r['quant_score']:.0f}점[{r['quant_rec']}]",
f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}",
"",
f"<b>📝 투자논거</b>\n{r['thesis']}",
]
if r.get("key_points"):
lines.append("\n<b>✅ 핵심근거</b>")
lines += [f"{p}" for p in r["key_points"][:5]]
if r.get("risks"):
lines.append("\n<b>⚠️ 리스크</b>")
lines += [f"{p}" for p in r["risks"][:4]]
if r.get("catalyst_watch"):
lines.append("\n<b>👀 관전포인트</b>")
lines += [f"{p}" for p in r["catalyst_watch"][:3]]
tp, sl = r.get("target_price", 0), r.get("stop_loss", 0)
if tp or sl:
lines.append(f"\n🎯 목표가 {tp:,}원 · 손절가 {sl:,}")
lines.append("\n<i>※ 투자 판단·책임은 본인에게 있습니다</i>")
return "\n".join(lines)
@app.get("/deep-analysis/{code}")
async def deep_analysis(code: str,
refresh: bool = Query(default=True),
notify: bool = Query(default=False),
include_context: bool = Query(default=False)):
"""RAG + EXAONE 종목 심층분석 (온디맨드). refresh=false면 당일 저장본 반환."""
async with pg_pool.acquire() as conn:
if not refresh:
cached = await conn.fetchrow("""
SELECT stock_name, recommendation, conviction, target_price,
stop_loss, thesis, report, quant_score, rag_context
FROM deep_analysis
WHERE stock_code=$1 AND analysis_date=CURRENT_DATE
""", code)
if cached:
rep = _jload(cached["report"])
out = {"code": code, "name": cached["stock_name"],
"quant_score": float(cached["quant_score"] or 0),
"quant_rec": "-", "cached": True, **rep}
if include_context:
out["rag_context"] = cached["rag_context"]
if notify:
await send_telegram(_fmt_deep(out))
return out
async with httpx.AsyncClient() as client:
res = await run_deep_analysis(conn, client, code, save=True)
if include_context and not res.get("error"):
async with pg_pool.acquire() as conn:
res["rag_context"], _ = await _build_rag_context(conn, code)
if notify and not res.get("error"):
await send_telegram(_fmt_deep(res))
return res
@app.post("/deep-analysis/batch")
async def deep_analysis_batch(kinds: str = Query(default="강력매수,매수관심"),
limit: int = Query(default=10, ge=1, le=30),
notify: bool = Query(default=True)):
"""최신 score_date 추천 종목을 일괄 심층분석 + 텔레그램 다이제스트"""
kind_list = [k.strip() for k in kinds.split(",") if k.strip()]
sell_only = all(k in ("매도관심", "강력매도") for k in kind_list)
order = "ASC" if sell_only else "DESC"
async with pg_pool.acquire() as conn:
rows = await conn.fetch(f"""
SELECT stock_code FROM stock_scores
WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
AND recommendation = ANY($1::text[])
ORDER BY total_score {order} LIMIT $2
""", kind_list, limit)
codes = [r["stock_code"] for r in rows]
results = []
async with httpx.AsyncClient() as client:
for c in codes:
results.append(await run_deep_analysis(conn, client, c, save=True))
ok = [r for r in results if not r.get("error")]
if notify and ok:
head = f"<b>🧠 AI 심층분석 다이제스트 ({date.today()})</b>\n대상 {len(ok)}종목\n"
digest = [head]
for r in ok:
ic = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "",
"매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "")
digest.append(f"{ic} {r['name']}({r['code']}) "
f"<b>{r['recommendation']}</b> 확신{r['conviction']}/5 "
f"· 퀀트{r['quant_score']:.0f}")
await send_telegram("\n".join(digest))
return {"analyzed": len(codes), "ok": len(ok),
"results": [{"code": r["code"], "name": r.get("name"),
"recommendation": r.get("recommendation"),
"conviction": r.get("conviction"),
"error": r.get("error")} for r in results]}
async def deep_analysis_batch_job():
"""평일 17:00 — 당일 추천종목 자동 심층분석 (16:30 스코어링 이후)"""
try:
await deep_analysis_batch(kinds="강력매수,매수관심", limit=10, notify=True)
logger.info("deep_batch.done")
except Exception as e:
logger.error("deep_batch.err", error=str(e))