6d3b0bacc0
- 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>
3156 lines
142 KiB
Python
3156 lines
142 KiB
Python
"""
|
||
종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반)
|
||
- 펀더멘털 점수 (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))
|