Files
trading/score-engine/main.py
T
kyu b3c5032f72 fix: 트레이딩 로직/AI 정확도 8종 수정 (Altman 과발생·CV누수·MACD·손절역전)
트레이딩 전문가 관점 로직 감사 후 라이브 추천·자동매매 영향 8종 수정.

[명백한 버그]
- MACD 시그널선: macd_line[-9:] 9개만 EMA→평활 거의 안 됨. 전체 시리즈로 수정 (ta-engine)
- 매수 손절가 역전: 하락추세(price<MA60)서 ma60*0.95가 진입가 위로 올라가 RR 역전·즉시손절
  → [-10%,-4%] 밴드로 클램프. 매도(숏)도 대칭 수정 (ta-engine)
- 12-1 모멘텀: closes[-1]은 보유데이터 길이(200~259)따라 룩백 가변→closes[min(251,len-1)] 12개월 고정

[모델 품질]
- Ridge 무스케일 학습: 피처 -100~수천 혼재로 L2 왜곡. StandardScaler 파이프라인 +
  계수 원본공간 역변환 저장(predict-price 무변경, 재구성 오차 3.5e-15)
- CV 패널 누수: 인덱스 분할이 같은 score_date를 train/test로 가르고 +30일 라벨윈도가
  test 피처와 겹침. 날짜분할 + 임바고(7d→12일/30d→39일)로 차단→정직한 IC/r2

[캘리브레이션·리스크]
- Altman Z: 2항 변형에 원본 4항 임계값(2.6/1.1) 적용→전시장 31%가 '부도위험' 오발생.
  실측분포(중앙값1.98/p10 0.51) 기준 부도0.7/안전3.0 재보정
- 일일 -3% halt: realized_pnl만 봐서 평가손실·무매도일 누락. 미실현손익 합산으로 수정

빌드·재기동·라이브 스모크 검증 완료. 자동매매 capital 실잔고 연동은 향후 과제(paper모드 무관).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:41:52 +09:00

7038 lines
332 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반)
- 펀더멘털 점수 (ROE, 영업이익률, 부채비율, PER/PBR) 30%
- 뉴스 감성 점수 25%
- 공시 점수 (DART) 15%
- 기술적 분석 점수 20%
- 가격 모멘텀 점수 10%
- 가치 필터: PER 0~40, 부채비율<80%, 영업이익>0, 시총>100억
- 매일 장 마감 후 자동 집계 + 텔레그램 알림
"""
import asyncio, json, os, pickle
from datetime import datetime, date, timedelta, timezone
from typing import Optional, Literal
from pydantic import BaseModel, Field
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")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
pg_pool: Optional[asyncpg.Pool] = None
redis_cl: Optional[aioredis.Redis] = None
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# ── 텔레그램 알림 ──────────────────────────────────────────
async def send_telegram(msg: str, reply_markup: dict | None = None):
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
payload = {"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"}
if reply_markup:
payload["reply_markup"] = reply_markup
async with httpx.AsyncClient() as c:
await c.post(
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
json=payload, timeout=10)
except Exception as e:
logger.warning("telegram.err", error=str(e))
def order_inline_buttons(order_id: int, side: str = "buy") -> dict:
"""주문 confirm/cancel inline 버튼 — 매수/매도 라벨 분리."""
confirm_label = "✅ 매수 실행" if side == "buy" else "✅ 매도 실행"
return {"inline_keyboard": [[
{"text": confirm_label, "callback_data": f"confirm:{order_id}"},
{"text": "❌ 취소", "callback_data": f"cancel:{order_id}"},
]]}
# ── 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)")
# 사용자 보유 포트폴리오 — 기존 테이블에 active 컬럼만 보강
await conn.execute("""
CREATE TABLE IF NOT EXISTS user_portfolio (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) NOT NULL DEFAULT '',
buy_price INTEGER NOT NULL,
qty INTEGER NOT NULL DEFAULT 1,
memo TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute(
"ALTER TABLE user_portfolio ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT true")
# Phase 4: 자동매매 테이블 (trading_orders, trading_daily_pnl)
await ensure_trading_tables()
async with pg_pool.acquire() as conn:
# 하이브리드 검증 + 라벨링 컬럼 (EXAONE + Gemini 둘 다 저장 + 사후검증)
for col, typ in [
("gemini_recommendation", "VARCHAR(20)"),
("gemini_conviction", "INTEGER DEFAULT 0"),
("gemini_thesis", "TEXT DEFAULT ''"),
("gemini_target_price", "BIGINT DEFAULT 0"),
("gemini_stop_loss", "BIGINT DEFAULT 0"),
("gemini_report", "JSONB DEFAULT '{}'::jsonb"),
("agreement", "BOOLEAN"),
("llm_cost_krw", "INTEGER DEFAULT 0"),
# 라벨링: Gemini 답을 학습용 정답(pseudo-ground-truth)으로 보관
("training_label", "VARCHAR(20)"), # Gemini 답 복사 (학습 정답)
("entry_price", "INTEGER DEFAULT 0"),
# 사후 검증 (30일 후 실제 수익률 측정)
("realized_price_30d", "INTEGER"),
("realized_return_30d", "DOUBLE PRECISION"),
("gemini_correct", "BOOLEAN"), # 30d 수익률 vs Gemini 판단
("exaone_correct", "BOOLEAN"), # 30d 수익률 vs EXAONE 판단
("verified_at", "TIMESTAMP"),
]:
await conn.execute(
f"ALTER TABLE deep_analysis ADD COLUMN IF NOT EXISTS {col} {typ}")
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()
)
""")
# 레짐/섹터 분리 학습용 segment 컬럼 + 복합 PK
try:
await conn.execute(
"ALTER TABLE weight_config ADD COLUMN IF NOT EXISTS segment VARCHAR(40) DEFAULT 'all'")
await conn.execute("ALTER TABLE weight_config DROP CONSTRAINT IF EXISTS weight_config_pkey")
await conn.execute(
"ALTER TABLE weight_config ADD CONSTRAINT weight_config_pkey PRIMARY KEY (config_date, segment)")
except Exception:
pass
# ─── 감성평가 보강: news_analysis 신규 컬럼 ────────────
for col in [
"time_horizon VARCHAR(10) DEFAULT '단기'", # 즉시/단기/중기/장기
"impact_scope VARCHAR(10) DEFAULT '종목'", # 종목/섹터/시장
"llm_confidence FLOAT DEFAULT 0.5", # 0~1
"source_credibility FLOAT DEFAULT 0.5", # 0~1
"title_strength FLOAT DEFAULT 1.0", # 본문 대비 제목 강도
"stock_impacts JSONB DEFAULT '{}'::jsonb", # {code:weight}
"event_cluster_id VARCHAR(32) DEFAULT ''",
"is_event_seed BOOLEAN DEFAULT FALSE", # 사건 첫 뉴스 여부
"sector_hint VARCHAR(40) DEFAULT ''",
]:
try:
await conn.execute(f"ALTER TABLE news_analysis ADD COLUMN IF NOT EXISTS {col}")
except Exception:
pass
try:
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_news_cluster ON news_analysis(event_cluster_id)")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_news_time_horizon ON news_analysis(time_horizon)")
except Exception:
pass
# 출처별 신뢰도 (자동 갱신)
await conn.execute("""
CREATE TABLE IF NOT EXISTS news_source_credibility (
source VARCHAR(100) PRIMARY KEY,
credibility FLOAT DEFAULT 0.5,
sample_size INTEGER DEFAULT 0,
hit_ratio_3d FLOAT DEFAULT NULL,
avg_signed_return_3d FLOAT DEFAULT NULL,
last_updated TIMESTAMP DEFAULT NOW()
)
""")
# catalyst × time_horizon 별 사후 신뢰도
await conn.execute("""
CREATE TABLE IF NOT EXISTS sentiment_reliability (
catalyst VARCHAR(20) NOT NULL,
time_horizon VARCHAR(10) NOT NULL,
sample_size INTEGER DEFAULT 0,
avg_return_3d FLOAT DEFAULT NULL,
avg_return_7d FLOAT DEFAULT NULL,
hit_ratio_3d FLOAT DEFAULT NULL,
reliability_score FLOAT DEFAULT 1.0,
last_updated TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (catalyst, time_horizon)
)
""")
# 사건 클러스터 메타 (중복/재유포 트래킹)
await conn.execute("""
CREATE TABLE IF NOT EXISTS news_event_cluster (
cluster_id VARCHAR(32) PRIMARY KEY,
first_seen_at TIMESTAMP DEFAULT NOW(),
last_seen_at TIMESTAMP DEFAULT NOW(),
member_count INTEGER DEFAULT 1,
seed_sentiment VARCHAR(10) DEFAULT '중립',
seed_intensity INTEGER DEFAULT 0,
seed_catalyst VARCHAR(20) DEFAULT '기타'
)
""")
# 학습 모델 평가지표 (walk-forward CV 결과)
await conn.execute("""
CREATE TABLE IF NOT EXISTS model_metrics (
id SERIAL PRIMARY KEY,
model_date DATE NOT NULL,
model_type VARCHAR(40) NOT NULL,
segment VARCHAR(40) DEFAULT 'all',
target VARCHAR(20) DEFAULT 'return_30d',
period_days INTEGER DEFAULT 0,
sample_size INTEGER DEFAULT 0,
n_folds INTEGER DEFAULT 0,
ic_spearman FLOAT DEFAULT NULL,
ic_pearson FLOAT DEFAULT NULL,
hit_ratio FLOAT DEFAULT NULL,
top_decile_spread FLOAT DEFAULT NULL,
sharpe_proxy FLOAT DEFAULT NULL,
r2_oos FLOAT DEFAULT NULL,
mae FLOAT DEFAULT NULL,
feature_importance JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mm_date ON model_metrics(model_date DESC)")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mm_segment ON model_metrics(segment, model_type)")
# 학습 모델 가중치 본체 (피처 확장 + 레짐/섹터 분리 지원)
await conn.execute("""
CREATE TABLE IF NOT EXISTS pricing_model_v2 (
id SERIAL PRIMARY KEY,
model_date DATE NOT NULL,
segment VARCHAR(40) DEFAULT 'all',
model_type VARCHAR(20) NOT NULL,
target VARCHAR(20) DEFAULT 'return_30d',
feature_names JSONB DEFAULT '[]'::jsonb,
feature_importance JSONB DEFAULT '{}'::jsonb,
coef JSONB DEFAULT '{}'::jsonb,
intercept FLOAT DEFAULT 0,
r2_oos FLOAT DEFAULT NULL,
ic_spearman FLOAT DEFAULT NULL,
hit_ratio FLOAT DEFAULT NULL,
sample_size INTEGER DEFAULT 0,
period_days INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(model_date, segment, model_type, target)
)
""")
await conn.execute(
"ALTER TABLE pricing_model_v2 ADD COLUMN IF NOT EXISTS model_blob BYTEA")
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 _close_near(conn, code: str, target: date) -> Optional[float]:
"""target일 또는 그 직전 거래일(최대 7일 소급)의 종가. 주말·휴장일 보정용."""
row = await conn.fetchrow("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 AND dt <= $2 AND dt >= $2 - 7
ORDER BY dt DESC LIMIT 1
""", code, target)
if not row or not row["close_price"] or float(row["close_price"]) <= 0:
return None
return float(row["close_price"])
async def _kospi_return_between(conn, start_date: date, end_date: date) -> Optional[float]:
"""KOSPI 두 날짜 사이 수익률 (%) — 거래일 보정(_close_near)"""
s = await _close_near(conn, "KOSPI", start_date)
e = await _close_near(conn, "KOSPI", end_date)
if s is None or e is None or s <= 0:
return None
return (e - s) / s * 100
async def update_performance_prices(force: bool = False):
"""추천 7일/30일 후 수익률 + KOSPI 대비 알파.
수익률 = (rec_date+N일 OHLCV 종가 entry_price) / entry_price.
종가는 stock_ohlcv 거래일 보정(_close_near) — KOSPI 알파와 horizon 일치.
force=True면 측정 완료 행도 OHLCV 기준으로 재계산(라벨 정정용)."""
measured_7d = measured_30d = skipped = 0
async with pg_pool.acquire() as conn:
cond_7d = ("entry_price > 0" if force else
"price_7d = 0 AND entry_price > 0 "
"AND rec_date <= CURRENT_DATE - 7 AND rec_date >= CURRENT_DATE - 60")
rows_7d = await conn.fetch(
f"SELECT id, stock_code, entry_price, rec_date "
f"FROM recommendation_performance WHERE {cond_7d}")
for row in rows_7d:
target = row["rec_date"] + timedelta(days=7)
if target > date.today():
continue
price = await _close_near(conn, row["stock_code"], target)
if price is None:
skipped += 1
continue
ret = (price - row["entry_price"]) / row["entry_price"] * 100
kospi_ret = await _kospi_return_between(conn, row["rec_date"], target)
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
""", int(price), ret, kospi_ret, alpha, row["id"])
measured_7d += 1
cond_30d = ("entry_price > 0" if force else
"price_30d = 0 AND entry_price > 0 "
"AND rec_date <= CURRENT_DATE - 30 AND rec_date >= CURRENT_DATE - 120")
rows_30d = await conn.fetch(
f"SELECT id, stock_code, entry_price, rec_date "
f"FROM recommendation_performance WHERE {cond_30d}")
for row in rows_30d:
target = row["rec_date"] + timedelta(days=30)
if target > date.today():
continue
price = await _close_near(conn, row["stock_code"], target)
if price is None:
skipped += 1
continue
ret = (price - row["entry_price"]) / row["entry_price"] * 100
kospi_ret = await _kospi_return_between(conn, row["rec_date"], target)
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
""", int(price), ret, kospi_ret, alpha, row["id"])
measured_30d += 1
logger.info("performance.updated", measured_7d=measured_7d,
measured_30d=measured_30d, skipped=skipped, force=force)
return {"measured_7d": measured_7d, "measured_30d": measured_30d, "skipped": skipped}
def get_recommendation(score: float, buy_votes: int = 0, sell_votes: int = 0,
ret_5d: float = 0.0, ret_20d: float = 0.0) -> str:
"""
임계값 + 다수공식 동의 강제 + 단기 약세 가드
- 강력매수: 점수 ≥70 AND 매수 ≥2 (가중치>0 공식 한정)
- 매수관심: 점수 ≥40 AND 매수≥1 AND 매도<2
- 강력매도: 점수 ≤-60 OR 매도≥3
- 매도관심: 점수 ≤-30 OR 매도≥2
- 그 외: 관망
※ buy/sell_votes는 학습이 가중치 0으로 가지친 공식 제외하고 카운트됨.
단기 약세 가드: 펀더가 좋아도 최근 가격이 무너지면 등급 강등 (떨어지는 칼날 회피)
- 강력매수 → 매수관심: ret_5d ≤ -5% OR ret_20d ≤ -10%
- 매수관심 → 관망: ret_5d ≤ -8% OR ret_20d ≤ -15%
"""
if score >= 70 and buy_votes >= 2:
rec = "강력매수"
elif score >= 40 and buy_votes >= 1 and sell_votes < 2:
rec = "매수관심"
elif score <= -60 or sell_votes >= 3:
rec = "강력매도"
elif score <= -30 or sell_votes >= 2:
rec = "매도관심"
else:
rec = "관망"
if rec == "강력매수" and (ret_5d <= -5.0 or ret_20d <= -10.0):
rec = "매수관심"
if rec == "매수관심" and (ret_5d <= -8.0 or ret_20d <= -15.0):
rec = "관망"
return rec
async def calc_short_returns(conn, stock_code: str, as_of: date | None = None) -> tuple[float, float]:
"""
최근 5일·20일 수익률(%) — 단기 약세 가드용.
데이터 부족 시 (0.0, 0.0) 반환 (가드 미작동 = 안전 fallback).
"""
if as_of is None:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 25
""", stock_code)
else:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 AND dt <= $2 ORDER BY dt DESC LIMIT 25
""", stock_code, as_of)
closes = [float(r["close_price"]) for r in rows if r["close_price"] and r["close_price"] > 0]
if len(closes) < 6:
return 0.0, 0.0
latest = closes[0]
p_5d = closes[5]
ret_5d = (latest - p_5d) / p_5d * 100 if p_5d > 0 else 0.0
if len(closes) >= 21:
p_20d = closes[20]
ret_20d = (latest - p_20d) / p_20d * 100 if p_20d > 0 else 0.0
else:
ret_20d = 0.0
return ret_5d, ret_20d
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, rsi: float | None = None) -> tuple[float, str]:
"""공매도 점수 (-100~+100). 기본은 공매도 많을수록 패널티지만,
공매도 과다 + 과매도(RSI≤35)면 숏커버링 반등(스퀴즈) 기대로 가산 — 양방향."""
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
# 숏스퀴즈 기대: 공매도 과다(거래비중≥5%) + 과매도(RSI≤35) → 숏커버링 반등 가능
# → 단방향 패널티를 상쇄/가산 (공매도 많을수록·과매도 깊을수록 가산 ↑, 최대 +35)
if rsi is not None and weight >= 5.0 and rsi <= 35.0:
squeeze = min(35.0, (weight - 5.0) * 2.0 + (35.0 - rsi) * 0.8)
score += squeeze
reason = (reason or "") + f" 숏스퀴즈기대(RSI{rsi:.0f})"
return max(-100.0, min(100.0, score)), reason
# ── H1: 5년 재무 추세 점수 ────────────────────────────────
async def calc_trend_score(conn, stock_code: str, as_of: date | None = None) -> tuple[float, str]:
"""
최근 5년치 사업보고서 ROE/영업이익률의 일관성·추세 점수 (-30~+30)
버핏: 안정적이고 우상향하는 수익성 선호.
as_of=date면 그 시점 이전 공시된 보고서만 사용 (look-ahead bias 차단).
"""
if as_of is None:
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)
else:
rows = await conn.fetch(f"""
SELECT bsns_year, roe, operating_margin
FROM dart_financials
WHERE stock_code=$1 AND reprt_code='11011' AND roe IS NOT NULL
AND ((bsns_year::int + 1)::text || '-04-01')::date <= $2
ORDER BY bsns_year DESC LIMIT 5
""", stock_code, as_of)
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'' 비제조업 모델의 2항 변형 (운전자본·이익잉여금 데이터 부재로 X1·X2 항 생략).
Z_simple = 6.72*(EBIT/총자산) + 1.05*(시총/총부채)
※ 임계값 재보정: 원본 Z''의 안전선 2.6은 4항(X1·X2 포함) 기준값이라
2항만 쓰는 이 변형에 그대로 적용하면 Z가 체계적으로 낮게 나와 부도 신호가 과발생함
(실측: 전종목 중앙값 Z≈2.0, p10≈0.5, p75≈5.1인데 <1.1 적용 시 ~31%'부도위험').
→ 2항 변형의 실측 분포에 맞춰 부도선 0.7(하위 ~15%), 안전선 3.0(상위 ~40%)로 보정.
> 3.0 안전 / 0.7~3.0 회색 / <0.7 부도위험
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 >= 3.0:
return round(z, 2), "매수", f"Altman Z {z:.1f} (안전)"
if z >= 0.7:
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, as_of: date | None = None) -> tuple[float, str, str]:
"""
AQR 스타일 12-1개월 모멘텀: (P_t-21 / P_t-252) - 1
최근 1개월 제외(반전효과 회피)한 11개월 수익률.
as_of=date면 그 시점 이전 가격만 사용.
"""
if as_of is None:
rows = await conn.fetch("""
SELECT close_price, dt FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 260
""", stock_code)
else:
rows = await conn.fetch("""
SELECT close_price, dt FROM stock_ohlcv
WHERE stock_code=$1 AND dt <= $2 ORDER BY dt DESC LIMIT 260
""", stock_code, as_of)
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) 전
# 12개월 전(거래일 252) 고정. closes[-1]을 쓰면 보유 데이터 길이(200~259)에 따라
# 룩백이 종목마다 달라져 모멘텀 비교가 불가능해짐 → 252봉으로 캡(부족 시 최장 사용).
p_year = closes[min(251, len(closes) - 1)][1]
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, as_of: date | None = None) -> tuple[float, str, str]:
"""
Amihud (2002): ILLIQ = avg(|return| / 거래대금) × 1e9
소형주 비유동성 프리미엄 — 높을수록 알파 잠재력 ↑ but 거래 어려움
20일 평균 사용 (1년 미만 데이터에서도 작동).
as_of=date면 그 시점 이전 가격/거래량만 사용.
"""
if as_of is None:
rows = await conn.fetch("""
SELECT close_price, volume FROM stock_ohlcv
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 21
""", stock_code)
else:
rows = await conn.fetch("""
SELECT close_price, volume FROM stock_ohlcv
WHERE stock_code=$1 AND dt <= $2 ORDER BY dt DESC LIMIT 21
""", stock_code, as_of)
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, as_of: date | None = None) -> tuple[float, str, str]:
"""
종목 일별 수익률 vs KOSPI 60일 회귀 베타
BAB(Betting Against Beta) 알파: 저베타 종목이 위험조정 후 우월
β < 0.7 매수 (저베타 알파), β > 1.5 매도 (고베타 위험), 그 사이 관망.
as_of=date면 그 시점 이전 가격만 사용.
"""
if as_of is None:
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)
else:
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 AND s.dt <= $3
ORDER BY s.dt DESC LIMIT $2
""", stock_code, days + 1, as_of)
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), "관망", ""
# ── 앙상블 보팅 (공식별 신호 다수결) ───────────────────────
# 앙상블 보팅 10공식 — formula_weights 기본값·learn-weights 학습 대상의 단일 출처.
# sig_map의 graph는 모델 신호라 학습 제외(formula_weights에 1.0 고정).
ENSEMBLE_FORMULAS = ["magic", "fscore", "altman", "peg", "momentum",
"beneish", "gpa", "gscore", "amihud", "beta"]
# 경기민감(시클리컬) 섹터 — 실적 정점 = 주가 고점 함정 가드용 (dart_corps.sector 기준)
CYCLICAL_SECTORS = {"전자/반도체", "화학", "1차금속", "기계",
"전기장비", "자동차", "운수장비", "비금속광물"}
def aggregate_signals(signals: dict, weights: dict | None = None) -> tuple[str, dict]:
"""
signals: {공식이름: '매수'/'매도'/'관망'}
weights: {공식이름: float} — 주어지면 가중치 > 0인 공식만 카운트(학습이 죽인 공식 제외).
graph는 학습 제외이므로 weights에 없어도 항상 카운트.
returns: (요약문, 카운트 dict)
"""
counts = {"매수": 0, "매도": 0, "관망": 0}
for fname, s in signals.items():
if weights is not None and fname in ENSEMBLE_FORMULAS:
if weights.get(fname, 0.0) <= 0:
continue
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 + A/B/C/D/E) ──────────────────
import math as _math
# catalyst 가중치 (정규화된 라벨 기준)
CATALYST_WEIGHTS = {
"실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4,
"M&A": 1.3, "신제품": 1.2, "규제": 1.3, "정책": 1.2,
"기타": 1.0, "모멘텀": 0.8,
}
# 세분 catalyst → 정규화 그룹 (EXAONE이 자유 형식으로 뱉어내는 라벨을 통합)
_CATALYST_PATTERNS = [
("실적", ["실적", "영업이익", "매출", "순이익", "어닝", "분기실적", "흑자", "적자", "감익", "증익"]),
("수주", ["수주", "계약", "공급", "납품", "선정"]),
("배당", ["배당", "환원", "자사주"]),
("리스크", ["리스크", "악재", "소송", "징계", "리콜", "조사", "조작", "회계", "감리", "제재"]),
("M&A", ["인수", "합병", "분할", "지분", "스왑"]),
("신제품", ["신제품", "출시", "런칭", "공개", "발표"]),
("규제", ["규제", "법안", "허가", "인증", "승인", "심사"]),
("정책", ["정책", "정부", "지원금", "보조금", "세제", "예산"]),
("모멘텀", ["모멘텀", "기대감", "전망", "관심"]),
]
def _map_catalyst(raw: str | None) -> str:
if not raw: return "기타"
s = str(raw).strip()
if s in CATALYST_WEIGHTS: return s
for group, keys in _CATALYST_PATTERNS:
if any(k in s for k in keys):
return group
return "기타"
def _time_weight(age_days: float, halflife_days: float = 3.0) -> float:
"""exp 시간감쇠. 3일 반감기 (0d→1.0, 3d→0.5, 7d→0.20)."""
return _math.exp(-age_days / max(halflife_days, 0.5) * _math.log(2))
def _similar_weight(similar_count: int | None) -> float:
"""동일 사건 다수 매체 보도 보정 — sqrt 스케일링, cap 2.5x."""
n = max(1, int(similar_count or 1))
return min(2.5, _math.sqrt(n))
# 사후 학습된 reliability/credibility 캐시 (calculate_daily_scores 시작 시 1회 로드)
_RELIABILITY_CACHE: dict = {} # {(catalyst, time_horizon): reliability_score}
_SRC_CRED_CACHE: dict = {} # {source: credibility} — DB 학습값 (sample≥20)
async def _load_reliability_caches(conn, backfill_mode: bool = False):
"""일 1회 호출 — 사후 검증 잡이 채운 신뢰도 테이블을 메모리 캐시로 로드.
backfill_mode=True면 캐시를 비워둠 (사후 학습된 신뢰도를 과거 시점에 적용하면 look-ahead bias).
"""
_RELIABILITY_CACHE.clear()
_SRC_CRED_CACHE.clear()
if backfill_mode:
return
try:
for r in await conn.fetch(
"SELECT catalyst, time_horizon, reliability_score, sample_size "
"FROM sentiment_reliability"):
if (r["sample_size"] or 0) >= 5:
_RELIABILITY_CACHE[(r["catalyst"], r["time_horizon"])] = float(r["reliability_score"] or 1.0)
for r in await conn.fetch(
"SELECT source, credibility, sample_size FROM news_source_credibility"):
if (r["sample_size"] or 0) >= 20:
_SRC_CRED_CACHE[r["source"]] = float(r["credibility"] or 0.5)
except Exception as e:
logger.warning("reliability.cache.err", error=str(e))
async def calc_news_score_weighted(
conn, stock_code: str, week_ago: date, now: datetime | None = None
) -> tuple[float, dict]:
"""
종합 가중치 = catalyst × intensity × 시간감쇠 × 중복가중
× 출처신뢰도 × 제목강도 × LLM신뢰도 × 사후신뢰도(reliability)
× 사건시드여부(첫 뉴스만 풀가중·후속 0.30)
× stock_impacts 매핑(1뉴스 N종목 영향도)
→ -100~+100. 동시에 pos/neg/neutral 카운트 반환.
primary_stock 외에 stock_impacts에 등장하는 종목도 가중치 비례 점수 포함.
"""
now = now or datetime.now(timezone.utc)
week_ago_dt = datetime.combine(week_ago, datetime.min.time(), tzinfo=timezone.utc)
# primary_stock=stock OR stock_impacts에 stock_code가 키로 등장하는 뉴스 전체
# 시간감쇠 기준: published_at (없으면 analyzed_at). 일부 RSS가 오래된 기사 재노출 →
# analyzed_at만 보면 11개월 전 기사도 풀가중 → 점수 왜곡. published_at 정렬·필터링 필수.
rows = await conn.fetch("""
SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst,
analyzed_at,
COALESCE(published_at, analyzed_at) AS ref_at,
COALESCE(similar_count, 1) AS sim,
COALESCE(time_horizon, '단기') AS time_horizon,
COALESCE(impact_scope, '종목') AS impact_scope,
COALESCE(llm_confidence, 0.5) AS llm_confidence,
COALESCE(source_credibility, 0.5) AS source_credibility,
COALESCE(title_strength, 1.0) AS title_strength,
COALESCE(is_event_seed, TRUE) AS is_event_seed,
COALESCE(stock_impacts, '{}'::jsonb) AS stock_impacts,
source
FROM news_analysis
WHERE (primary_stock=$1 OR stock_impacts ? $1)
AND COALESCE(published_at, analyzed_at) >= $2
""", stock_code, week_ago_dt)
if not rows:
return 0.0, {"pos": 0, "neg": 0, "neutral": 0, "total": 0}
score = 0.0
pos = neg = neutral = 0
for r in rows:
sent = r["sentiment"]
if sent == "중립":
neutral += 1
continue
if sent not in ("호재", "악재"):
continue
cat = _map_catalyst(r["catalyst"])
cw = CATALYST_WEIGHTS.get(cat, 1.0)
intensity = float(r["intensity"] or 1)
try:
age_days = (now - r["ref_at"]).total_seconds() / 86400.0
except Exception:
age_days = 3.0
tw = _time_weight(max(0.0, age_days))
sw = _similar_weight(r["sim"])
# 출처 신뢰도: 학습된 값 우선, 없으면 컬럼값 → 평균 0.5에서 ±0.5 범위 정규화 (0.5~1.5x)
src = r["source"] or ""
cred = _SRC_CRED_CACHE.get(src, float(r["source_credibility"] or 0.5))
cred_w = 0.5 + cred # 0.5~1.5
# LLM confidence: 0.3 이하면 절반 가중 (저신뢰 라벨 영향 축소)
llm_w = max(0.4, min(1.2, float(r["llm_confidence"] or 0.5) + 0.4))
# 제목 강도: 0.3~1.5 (news-collector에서 clamp됨)
ts_w = float(r["title_strength"] or 1.0)
# 사후 학습 reliability: catalyst×time_horizon 평균 hit_ratio 기반 (0.2~2.0)
th = r["time_horizon"] or "단기"
rel_w = _RELIABILITY_CACHE.get((cat, th), 1.0)
# 사건 시드 여부: 클러스터 첫 뉴스만 풀 가중, 후속은 0.30로 감쇠
seed_w = 1.0 if r["is_event_seed"] else 0.30
# 1뉴스 N종목 영향도: stock_impacts에 매핑된 가중치 (primary는 1.0)
try:
si = r["stock_impacts"]
if isinstance(si, str):
try: si = json.loads(si)
except: si = {}
si = si or {}
except Exception:
si = {}
# primary_stock 매칭으로 들어온 행은 stock_impacts에 없어도 풀 가중(1.0).
# stock_impacts에 키로 등장하면 LLM이 매긴 영향도를 사용.
if stock_code in si:
impact = float(si.get(stock_code) or 0)
else:
impact = 1.0
if impact <= 0:
continue
# impact_scope: 시장 광역 뉴스는 종목 영향 보수적 감쇠
scope = r["impact_scope"]
if scope == "시장": impact *= 0.30
elif scope == "섹터": impact *= 0.60
delta = intensity * 5.0 * cw * tw * sw * cred_w * llm_w * ts_w * rel_w * seed_w * impact
if sent == "호재":
score += delta; pos += 1
else:
score -= delta; neg += 1
return max(-100.0, min(100.0, score)), {
"pos": pos, "neg": neg, "neutral": neutral, "total": len(rows)
}
async def calc_sentiment_momentum(
conn, stock_code: str, now: datetime | None = None
) -> float:
"""
최근 3일 가중 sentiment 합 - 그 이전 4일 가중 sentiment 합 → 모멘텀 (-50~+50).
"""
now = now or datetime.now(timezone.utc)
cutoff_recent = now - timedelta(days=3)
cutoff_old = now - timedelta(days=7)
rows = await conn.fetch("""
SELECT sentiment, intensity,
COALESCE(published_at, analyzed_at) AS ref_at,
COALESCE(similar_count, 1) AS sim,
COALESCE(catalyst, '기타') AS catalyst
FROM news_analysis
WHERE primary_stock=$1
AND COALESCE(published_at, analyzed_at) >= $2
AND sentiment IN ('호재','악재')
""", stock_code, cutoff_old)
recent = old = 0.0
for r in rows:
cw = CATALYST_WEIGHTS.get(_map_catalyst(r["catalyst"]), 1.0)
sw = _similar_weight(r["sim"])
val = float(r["intensity"] or 1) * cw * sw
if r["sentiment"] == "악재": val = -val
if r["ref_at"] >= cutoff_recent:
recent += val
else:
old += val
# 일평균으로 정규화 (3일 vs 4일) 후 차이 → 약간 증폭
momentum = (recent / 3.0) - (old / 4.0)
return max(-50.0, min(50.0, momentum * 2.0))
async def calc_news_surge_and_attention(
conn, stock_code: str, now: datetime | None = None
) -> tuple[float, float]:
"""
(surge_ratio, attention_score) 반환.
surge_ratio = 최근 7일 일평균 뉴스 / 이전 28일 일평균 뉴스 (>1 = 평소보다 폭증).
attention_score = 최근 7일 전체 뉴스 건수 (중립 포함) — 50건 이상이면 100 cap, log 스케일.
"""
now = now or datetime.now(timezone.utc)
row = await conn.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE analyzed_at >= $2) AS recent7,
COUNT(*) FILTER (WHERE analyzed_at >= $3 AND analyzed_at < $2) AS prev28
FROM news_analysis WHERE primary_stock=$1
""", stock_code, now - timedelta(days=7), now - timedelta(days=35))
recent7 = int(row["recent7"] or 0)
prev28 = int(row["prev28"] or 0)
rate_recent = recent7 / 7.0
rate_prev = max(prev28 / 28.0, 0.05)
surge = rate_recent / rate_prev
surge = max(0.0, min(10.0, surge))
attention = min(100.0, _math.log1p(recent7) * 25.0) # 0건→0, 7건→55, 30건→85, 50건→100
return float(surge), float(attention)
# ── 사후 검증 피드백 루프 ─────────────────────────────────
async def calibrate_sentiment_reliability():
"""
catalyst × time_horizon × sentiment별로 ±3/7일 가격반응 측정 → reliability_score 갱신.
감성과 부합한 비율(hit_ratio)이 높을수록 reliability ↑.
8일 전 ~ 120일 전 분석된 뉴스만 사용 (7일 수익률 확정 + 너무 옛것 제외).
"""
from collections import defaultdict
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
WITH n AS (
SELECT na.primary_stock AS code, na.sentiment, na.intensity,
COALESCE(na.catalyst, '기타') AS catalyst,
COALESCE(na.time_horizon, '단기') AS th,
na.analyzed_at::date AS d0,
COALESCE(na.is_event_seed, TRUE) AS seed
FROM news_analysis na
WHERE na.primary_stock IS NOT NULL AND na.primary_stock != ''
AND na.sentiment IN ('호재','악재')
AND na.intensity >= 2
AND na.analyzed_at >= NOW() - INTERVAL '120 days'
AND na.analyzed_at <= NOW() - INTERVAL '8 days'
)
SELECT n.catalyst, n.th, n.sentiment, n.intensity, n.code, n.d0,
(SELECT close_price FROM stock_ohlcv
WHERE stock_code=n.code AND dt >= n.d0
ORDER BY dt ASC LIMIT 1) AS p0,
(SELECT close_price FROM stock_ohlcv
WHERE stock_code=n.code AND dt >= n.d0 + INTERVAL '3 days'
ORDER BY dt ASC LIMIT 1) AS p3,
(SELECT close_price FROM stock_ohlcv
WHERE stock_code=n.code AND dt >= n.d0 + INTERVAL '7 days'
ORDER BY dt ASC LIMIT 1) AS p7
FROM n
WHERE n.seed = TRUE
""")
buckets = defaultdict(lambda: {"r3": [], "r7": [], "hit3": 0, "n": 0})
for r in rows:
p0 = float(r["p0"] or 0)
if p0 <= 0: continue
sign = 1 if r["sentiment"] == "호재" else -1
key = (r["catalyst"], r["th"])
b = buckets[key]
b["n"] += 1
if r["p3"]:
r3 = (float(r["p3"]) - p0) / p0 * 100
b["r3"].append(sign * r3)
if sign * r3 > 0: b["hit3"] += 1
if r["p7"]:
r7 = (float(r["p7"]) - p0) / p0 * 100
b["r7"].append(sign * r7)
saved = 0
for (cat, th), b in buckets.items():
n = b["n"]
if n < 5: continue
avg3 = sum(b["r3"])/len(b["r3"]) if b["r3"] else None
avg7 = sum(b["r7"])/len(b["r7"]) if b["r7"] else None
n3 = len(b["r3"])
hit3 = b["hit3"]/n3 if n3 else None
# reliability_score: hit_ratio 기반 0.2~2.0
# 0.5(랜덤) 근처면 1.0, 0.7 → 1.5, 0.9 → 2.0, 0.3 → 0.4
rel = 1.0
if hit3 is not None:
rel = max(0.2, min(2.0, 0.2 + (hit3 - 0.30) * 2.5))
if avg3 is not None:
if avg3 > 0.8: rel = min(2.0, rel + 0.20)
elif avg3 < -0.5: rel = max(0.2, rel - 0.30)
await conn.execute("""
INSERT INTO sentiment_reliability
(catalyst, time_horizon, sample_size, avg_return_3d, avg_return_7d,
hit_ratio_3d, reliability_score, last_updated)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (catalyst, time_horizon) DO UPDATE SET
sample_size=$3, avg_return_3d=$4, avg_return_7d=$5,
hit_ratio_3d=$6, reliability_score=$7, last_updated=NOW()
""", cat, th, n, avg3, avg7, hit3, rel)
saved += 1
logger.info("sentiment.calibration.done", buckets=len(buckets), saved=saved)
return {"buckets": len(buckets), "saved": saved}
async def calibrate_source_credibility():
"""
뉴스 출처별 감성 ↔ 3일 가격반응 부합도 측정 → news_source_credibility 갱신.
"""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT na.source, na.sentiment,
na.primary_stock AS code,
na.analyzed_at::date AS d0,
(SELECT close_price FROM stock_ohlcv
WHERE stock_code=na.primary_stock AND dt >= na.analyzed_at::date
ORDER BY dt ASC LIMIT 1) AS p0,
(SELECT close_price FROM stock_ohlcv
WHERE stock_code=na.primary_stock AND dt >= na.analyzed_at::date + INTERVAL '3 days'
ORDER BY dt ASC LIMIT 1) AS p3
FROM news_analysis na
WHERE na.primary_stock IS NOT NULL AND na.primary_stock != ''
AND na.sentiment IN ('호재','악재') AND na.intensity >= 2
AND COALESCE(na.is_event_seed, TRUE) = TRUE
AND na.analyzed_at >= NOW() - INTERVAL '90 days'
AND na.analyzed_at <= NOW() - INTERVAL '5 days'
AND na.source IS NOT NULL AND na.source != ''
""")
from collections import defaultdict
agg = defaultdict(lambda: {"hit": 0, "n": 0, "r": []})
for r in rows:
p0 = float(r["p0"] or 0); p3 = float(r["p3"] or 0)
if p0 <= 0 or p3 <= 0: continue
sign = 1 if r["sentiment"] == "호재" else -1
ret = (p3 - p0) / p0 * 100
a = agg[r["source"]]
a["n"] += 1
a["r"].append(sign * ret)
if sign * ret > 0: a["hit"] += 1
saved = 0
for src, a in agg.items():
n = a["n"]
if n < 10: continue
hit = a["hit"] / n
avg = sum(a["r"]) / n
# credibility 0.2~1.0 (기본 0.5, hit_ratio·avg_return으로 보정)
cred = max(0.2, min(1.0, 0.30 + hit * 0.6 + max(-0.1, min(0.1, avg / 20))))
await conn.execute("""
INSERT INTO news_source_credibility
(source, credibility, sample_size, hit_ratio_3d, avg_signed_return_3d, last_updated)
VALUES ($1,$2,$3,$4,$5,NOW())
ON CONFLICT (source) DO UPDATE SET
credibility=$2, sample_size=$3, hit_ratio_3d=$4,
avg_signed_return_3d=$5, last_updated=NOW()
""", src[:100], cred, n, hit, avg)
saved += 1
logger.info("source.calibration.done", sources=len(agg), saved=saved)
return {"sources": len(agg), "saved": saved}
async def calc_market_sentiment_baseline(conn, week_ago: date) -> float:
"""전체 시장 종목당 평균 가중 sentiment (sentiment_alpha 산출용 baseline)."""
week_ago_dt = datetime.combine(week_ago, datetime.min.time(), tzinfo=timezone.utc)
row = await conn.fetchrow("""
SELECT AVG(per_stock)::float AS mean FROM (
SELECT primary_stock,
SUM(CASE
WHEN sentiment='호재' THEN intensity * 5.0
WHEN sentiment='악재' THEN -intensity * 5.0
ELSE 0 END) AS per_stock
FROM news_analysis
WHERE analyzed_at >= $1 AND primary_stock IS NOT NULL
AND sentiment IN ('호재','악재')
GROUP BY primary_stock
) t
""", week_ago_dt)
return float((row and row["mean"]) or 0.0)
# ── 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, as_of: date | None = None) -> tuple[str, float]:
"""
KOSPI 종가 vs 200일 이평으로 시장 레짐 판단
위면 강세(+5), 아래면 약세(-10), 데이터 없으면 중립
as_of=date이면 그 시점 기준 (백필용).
"""
if as_of is None:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code='KOSPI' ORDER BY dt DESC LIMIT 200
""")
else:
rows = await conn.fetch("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code='KOSPI' AND dt <= $1 ORDER BY dt DESC LIMIT 200
""", as_of)
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 < 30_000_000_000: # 300억 미만 (잡주 제외)
return False, "시총 300억 미만 (잡주)"
return True, ""
# ── 일간 점수 산출 ────────────────────────────────────────
def _disclosure_date_sql() -> str:
"""DART 보고서 종류별 표준 공시일 추정 SQL 표현식.
한국 공시 규정: 사업보고서(11011)는 사업연도 종료 후 90일 이내(다음 해 3/31),
분기/반기는 분기 종료 후 45일 이내. 안전하게 1일 여유 둠.
백필 모드에서 'estimated_disclosure_date <= as_of' 필터에 사용해 미래 보고서 누설 차단."""
return ("""(CASE reprt_code
WHEN '11011' THEN ((bsns_year::int + 1)::text || '-04-01')::date
WHEN '11012' THEN (bsns_year || '-05-16')::date
WHEN '11013' THEN (bsns_year || '-08-15')::date
WHEN '11014' THEN (bsns_year || '-11-15')::date
ELSE '9999-12-31'::date
END)""")
# ══════════════════════════════════════════════════════════
# 신규 보조 시그널 (임원매매 / 컨센서스 / 매크로 / 기관 / 밸류 percentile)
# ══════════════════════════════════════════════════════════
async def _load_insider_map(conn, as_of: date | None = None) -> dict:
"""최근 90일 임원·대주주 매매 집계. 종목별 (net_change, buy_cnt, sell_cnt, top_actor).
as_of=None이면 CURRENT_DATE 기준, as_of=date이면 그 시점 기준 (백필용)."""
if as_of is None:
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
""")
else:
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 BETWEEN $1::date - 90 AND $1::date
GROUP BY stock_code
""", as_of)
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, as_of: date | None = None) -> dict:
"""as_of=None이면 최근 30일 컨센서스. as_of=date(백필)이면 빈 dict 반환
(analyst_consensus는 최신값만 저장하고 시점 이력이 없어서 look-ahead bias 위험)."""
if as_of is not None:
return {}
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, as_of: date | None = None) -> dict:
"""최근 5일 vs 그 이전 5일 매크로 변동률. as_of=date면 그 시점 기준 20일 윈도우."""
if as_of is None:
rows = await conn.fetch("""
SELECT indicator, trade_date, value FROM macro_daily
WHERE trade_date >= CURRENT_DATE - 20
ORDER BY indicator, trade_date DESC
""")
else:
rows = await conn.fetch("""
SELECT indicator, trade_date, value FROM macro_daily
WHERE trade_date BETWEEN $1::date - 20 AND $1::date
ORDER BY indicator, trade_date DESC
""", as_of)
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, as_of: date | None = None) -> dict:
"""종목별 최근 5일 기관·외국인 순매수 합계. as_of=date면 그 시점 기준."""
if as_of is None:
rows = await conn.fetch("""
SELECT stock_code,
SUM(inst_net) AS inst5d,
SUM(foreign_net) AS for5d,
SUM(individual_net) AS ind5d,
AVG(close_price)::float AS avg_price
FROM inst_daily_flow
WHERE trade_date >= CURRENT_DATE - 7
GROUP BY stock_code
""")
else:
rows = await conn.fetch("""
SELECT stock_code,
SUM(inst_net) AS inst5d,
SUM(foreign_net) AS for5d,
SUM(individual_net) AS ind5d,
AVG(close_price)::float AS avg_price
FROM inst_daily_flow
WHERE trade_date BETWEEN $1::date - 7 AND $1::date
GROUP BY stock_code
""", as_of)
return {r["stock_code"]: dict(r) for r in rows}
def calc_inst_flow_signal(flow: dict) -> tuple[float, str]:
"""기관+개인 수급 시그널 (±15). 기관 순매수 가산(외국인 동행 시 가중),
개인은 컨트래리언: 개인 과열매수=감점, 개인이탈+큰손매집=가점."""
if not flow:
return 0.0, ""
inst5 = int(flow.get("inst5d") or 0)
for5 = int(flow.get("for5d") or 0)
ind5 = int(flow.get("ind5d") or 0)
if inst5 == 0 and for5 == 0 and ind5 == 0:
return 0.0, ""
import math
# 기관 시그널: tanh 스케일 (포화) + 외국인 동행 가중
inst_score = math.tanh(inst5 / 5_000_000) * 6.0
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
# 개인 컨트래리언: 순매수→감점(과열), 순매도→가점 (소폭)
ind_score = -math.tanh(ind5 / 5_000_000) * 3.0
smart = inst5 + for5
if ind5 > 0 and smart < 0: ind_score -= 2.0 # 개인이 받고 큰손 던짐 = 분산
elif ind5 < 0 and smart > 0: ind_score += 2.0 # 큰손 매집·개인 이탈 = 매집
ind_score = max(-5.0, min(5.0, ind_score))
sig = max(-15.0, min(15.0, inst_score + ind_score))
parts = []
if inst5 != 0:
parts.append(f"기관5d {'매수' if inst5>0 else '매도'}{abs(inst5)//10000:,}만주")
if abs(ind_score) >= 1.5:
parts.append(f"개인{'과열매수' if ind5>0 else '이탈'}")
return sig, " ".join(parts)
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(as_of: date | None = None, notify: bool = False):
"""일간 점수 계산. as_of=None이면 today (운영 모드), as_of=date이면 그 시점 기준 (백필 모드).
notify=True일 때만 텔레그램 일간리포트/신규강력매수 발신 (16:30 정기 1회만 True).
그 외 호출(통합 워크플로우 30분마다·매시간 스코어·18:30 등)은 silent로 점수만 갱신.
백필 모드는 look-ahead bias 차단:
- 사후 학습 캐시(reliability/source_credibility) 미적용
- weight_config는 config_date <= as_of 필터
- 컨센서스(이력 추적 없음) 제외
- DART 재무는 보고서 종류별 표준 공시일 추정으로 시점 필터
- 모든 데이터 조회를 as_of 기준 윈도우로 변경
"""
backfill_mode = as_of is not None
today = as_of or date.today()
week_ago = today - timedelta(days=7)
logger.info("scoring.start", as_of=str(today), backfill=backfill_mode)
strong_buy: list = []
strong_sell: list = []
# H3: KOSPI 일봉 갱신 후 시장 레짐 계산 — 백필 모드는 이미 있는 데이터 사용, 갱신 스킵
if not backfill_mode:
await fetch_kospi_ohlcv()
formula_weights = {f: 1.0 for f in ENSEMBLE_FORMULAS}
formula_weights["graph"] = 1.0
async with pg_pool.acquire() as conn:
# 사후 학습된 reliability/credibility 캐시 로드 — 백필 모드는 미적용 (look-ahead bias 차단)
await _load_reliability_caches(conn, backfill_mode=backfill_mode)
logger.info("reliability.cache.loaded",
reliability=len(_RELIABILITY_CACHE), source_cred=len(_SRC_CRED_CACHE))
# D: 시장 sentiment baseline 1회 계산 (전 종목 sentiment_alpha 산출용)
market_sentiment_baseline = await calc_market_sentiment_baseline(conn, week_ago)
logger.info("sentiment.market_baseline", value=round(market_sentiment_baseline, 2))
# H3: 시장 레짐 1회 계산 (전 종목 동일 적용)
regime_label, regime_adj = await calc_market_regime(conn, as_of=today if backfill_mode else None)
# 공식별 학습 가중치 로드 — 현재 regime 매칭 우선, 없으면 segment='all' fallback
# 백필 모드는 config_date <= as_of 필터 (그 시점 이전 학습본만 적용)
seg_priority = [f"regime:{regime_label}", "all"]
for seg in seg_priority:
if backfill_mode:
cfg = await conn.fetchrow(
"SELECT weights, sample_size FROM weight_config "
"WHERE segment=$1 AND config_date <= $2 "
"ORDER BY config_date DESC LIMIT 1", seg, today)
else:
cfg = await conn.fetchrow(
"SELECT weights, sample_size FROM weight_config "
"WHERE segment=$1 ORDER BY config_date DESC LIMIT 1", seg)
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])
logger.info("weights.loaded", segment=seg, sample=cfg["sample_size"])
break
except Exception as e:
logger.warning("weights.load_err", segment=seg, error=str(e))
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, as_of=today if backfill_mode else None)
consensus_map = await _load_consensus_map(conn, as_of=today if backfill_mode else None)
flow_map = await _load_inst_flow_map(conn, as_of=today if backfill_mode else None)
macro_state = await _load_macro_state(conn, as_of=today if backfill_mode else None)
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 서비스가 채움)
# 주주환원율 사전 로드: (배당금 + 자사주매입) / 시가총액 × 100 (%)
# 코리아 디스카운트 방어 — 돈 벌어도 주주와 안 나누면 주가가 안 오름
shareholder_yield_map: dict = {}
try:
sy_rows = await conn.fetch("""
SELECT d.stock_code,
COALESCE(dv.total_dividend, 0) AS dividend,
COALESCE(fn.treasury, 0) AS treasury,
COALESCE(mc.market_cap, 0) AS mcap
FROM dart_corps d
LEFT JOIN LATERAL (
SELECT total_dividend FROM dart_dividends
WHERE stock_code=d.stock_code ORDER BY bsns_year DESC LIMIT 1) dv ON true
LEFT JOIN LATERAL (
SELECT treasury_acquired AS treasury FROM dart_financials
WHERE stock_code=d.stock_code AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 1) fn ON true
LEFT JOIN LATERAL (
SELECT market_cap FROM stock_prices
WHERE stock_code=d.stock_code AND market_cap>0
ORDER BY collected_at DESC LIMIT 1) mc ON true
WHERE d.is_active=true
""")
for r in sy_rows:
mcap = float(r["mcap"] or 0)
if mcap <= 0:
continue
payout = float(r["dividend"] or 0) + float(r["treasury"] or 0)
shareholder_yield_map[r["stock_code"]] = payout / mcap * 100.0
logger.info("shareholder_yield.loaded", count=len(shareholder_yield_map))
except Exception as e:
logger.warning("shareholder_yield.load_err", error=str(e))
# 시그널 날짜는 미국장 마감 기준이라 한국 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)
# 백필 모드는 Redis·stock_prices 스킵하고 stock_ohlcv에서 그 시점 종가만 사용
price_score = 0.0
price_change = 0.0
has_price = False
per = pbr = market_cap = 0.0
if redis_cl and not backfill_mode:
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 (장중 수집 데이터) — 백필 모드 스킵 (30일 보존만)
if not has_price and not backfill_mode:
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 만료 시)
# 백필 모드: as_of 이전 종가만 사용. PER/PBR/시총은 0 (그 시점 데이터 없음).
if not has_price:
try:
if backfill_mode:
ov = await conn.fetchrow(
"SELECT close_price, foreign_ratio FROM stock_ohlcv "
"WHERE stock_code=$1 AND dt <= $2 ORDER BY dt DESC LIMIT 1",
stock, today)
else:
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; rsi_val = None
try:
ta_row = await conn.fetchrow(
"SELECT tech_score, rsi FROM stock_technical WHERE stock_code=$1", stock)
if ta_row:
technical_score = float(ta_row["tech_score"] or 0)
rsi_val = float(ta_row["rsi"]) if ta_row["rsi"] is not None else None
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, rsi=rsi_val)
short_weight_val = s_data[0].get("trade_weight", 0) if s_data else 0
except: pass
# 펀더멘털 점수 (dart_financials - 최신 사업보고서 기준)
# 백필 모드: 그 시점 이전 공시된 보고서만 사용 (estimated_disclosure_date <= as_of)
if backfill_mode:
fin_row = await conn.fetchrow(f"""
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
AND (CASE f.reprt_code
WHEN '11011' THEN ((f.bsns_year::int + 1)::text || '-04-01')::date
WHEN '11012' THEN (f.bsns_year || '-05-16')::date
WHEN '11013' THEN (f.bsns_year || '-08-15')::date
WHEN '11014' THEN (f.bsns_year || '-11-15')::date
ELSE '9999-12-31'::date
END) <= $2
ORDER BY f.bsns_year DESC, f.reprt_code DESC
LIMIT 1
""", stock, today)
else:
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)을 써야 영업이익/총자산 단위가 일치
if backfill_mode:
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'
AND ((bsns_year::int + 1)::text || '-04-01')::date <= $2
ORDER BY bsns_year DESC LIMIT 2
""", stock, today)
else:
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, as_of=today if backfill_mode else None)
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, as_of=today if backfill_mode else None)
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, as_of=today if backfill_mode else None)
if amihud_reason:
fin_reasons.append(amihud_reason)
# 시장 베타 (BAB — Frazzini-Pedersen 2014)
beta_val, beta_sig, beta_reason = await calc_beta(conn, stock, as_of=today if backfill_mode else None)
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, weights=formula_weights)
if ensemble_summary:
fin_reasons.append(f"공식보팅 [{ensemble_summary}]")
# M4 + A/C: catalyst 가중 + 시간감쇠 + similar_count 적용된 뉴스 점수
news_score_w, news_stats = await calc_news_score_weighted(conn, stock, week_ago)
news_score = news_score_w
# B: 감정 모멘텀 (3일 vs 4일 변화율)
sentiment_momentum = await calc_sentiment_momentum(conn, stock)
# E: 뉴스 surge + attention(중립 포함 총 관심도)
news_surge_ratio, attention_score = await calc_news_surge_and_attention(conn, stock)
# D: 시장 평균 대비 sentiment alpha (per-stock raw sum - 시장 평균)
sentiment_raw_sum = float(news_stats.get("pos", 0)) * 5.0 - float(news_stats.get("neg", 0)) * 5.0
sentiment_alpha = max(-100.0, min(100.0,
sentiment_raw_sum - market_sentiment_baseline))
# 펀더멘털 통합: 기존 + 추세 + 이익품질 + 매직포뮬러 + F-Score (DCF는 종합점수에 별도 가중)
# 주주환원율 보너스 (배당+자사주매입 / 시총) — 퀄리티 보완
sy_pct = shareholder_yield_map.get(stock, 0.0)
sy_bonus = (8.0 if sy_pct >= 6 else 5.0 if sy_pct >= 4
else 3.0 if sy_pct >= 2.5 else 1.0 if sy_pct >= 1 else 0.0)
if sy_bonus >= 5:
fin_reasons.append(f"주주환원율 {sy_pct:.1f}%")
fundamental_combined = max(-100.0, min(100.0,
fundamental_score + trend_score + eq_score + magic_score
+ f_score_adj + sy_bonus))
# 종합 점수 (가중치 재배분)
# 펀더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)
# B+D: 감정 모멘텀 + 시장 alpha 보너스 (max ±5)
sentiment_bonus = max(-5.0, min(5.0,
sentiment_momentum * 0.06 + sentiment_alpha * 0.03))
total += sentiment_bonus
if abs(sentiment_bonus) >= 1.5:
fin_reasons.append(
f"감정 {('+' if sentiment_bonus>0 else '')}{sentiment_bonus:.1f}"
f"(모멘텀 {sentiment_momentum:+.1f} · alpha {sentiment_alpha:+.0f})")
# E: news surge ≥3.0 + 뉴스점수 양수 → 강한 attention 신호로 +2 (악재 surge는 차감 안 함)
if news_surge_ratio >= 3.0 and news_score > 10:
total += 2.0
fin_reasons.append(f"뉴스 surge ×{news_surge_ratio:.1f}")
# 앙상블 보팅 가산점: 학습 가중치 적용 (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
# 사이클 고점 가드: 경기민감주가 급등(고모멘텀) + 저PER이면
# peak-earnings(실적 정점=주가 고점) 함정 위험 → 점수 차감
cyc_penalty = 0.0
if sector in CYCLICAL_SECTORS and 0 < per < 10 and mom_val > 80:
cyc_penalty = -min(12.0, 4.0 + mom_val / 40.0)
fin_reasons.append(
f"사이클 고점주의({sector}·모멘텀{mom_val:.0f}%·PER{per:.1f})")
# H3: 시장 레짐 + 앙상블 + 미국증시 + 5개 보조 + 사이클 가드
total = max(-100, min(100,
total + regime_adj + ensemble_bonus + us_adj + aux_total + cyc_penalty))
# 단기 약세 가드: 펀더 좋아도 최근 5d/20d 가격 무너지면 등급 강등
ret_5d, ret_20d = await calc_short_returns(
conn, stock, as_of=today if backfill_mode else None)
rec = get_recommendation(total, vote_counts["매수"], vote_counts["매도"],
ret_5d, ret_20d)
if rec in ("매수관심", "관망") and (ret_5d <= -5.0 or ret_20d <= -10.0):
fin_reasons.append(f"단기약세가드(5d{ret_5d:+.1f}%·20d{ret_20d:+.1f}%)")
# 보조 시그널 근거 (점수에 영향 큰 것만)
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,
sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio)
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,$46,$47,$48,$49)
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,
sentiment_momentum=$46, sentiment_alpha=$47,
attention_score=$48, news_surge_ratio=$49
""", stock, name, today,
news_stats["pos"], news_stats["neg"], news_stats.get("neutral", 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,
sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio)
# 미국증시 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
# 백필 모드: Redis 스킵, stock_ohlcv에서 그 시점 종가 사용 (look-ahead bias 차단)
if backfill_mode:
try:
price_row = await conn.fetchrow(
"SELECT close_price FROM stock_ohlcv "
"WHERE stock_code=$1 AND dt<=$2 ORDER BY dt DESC LIMIT 1",
r["stock_code"], today)
if price_row and price_row["close_price"]:
entry_price = int(price_row["close_price"])
except: pass
elif 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,$6)
ON CONFLICT (stock_code, rec_date) DO NOTHING
""", r["stock_code"], r["stock_name"], r["recommendation"], r["total_score"], entry_price, today)
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 notify and not backfill_mode and strong_buy:
try:
async with pg_pool.acquire() as nc:
prev_codes = {r["stock_code"] for r in await nc.fetch("""
SELECT stock_code FROM stock_scores
WHERE score_date = $1 AND recommendation='강력매수'
""", today - timedelta(days=1))}
today_codes = {code for _name, code, *_ in strong_buy}
new_codes = sorted(today_codes - prev_codes)
if new_codes:
lines = [f"🆕 <b>신규 강력매수 등장 ({today})</b>"]
async with httpx.AsyncClient() as cli:
for code in new_codes[:5]:
try:
res = await run_deep_analysis(nc, cli, code,
save=True, model="hybrid")
if res.get("error"):
continue
agr = res.get("agreement")
tag = "" if agr else ("⚠️" if agr is False else "")
lines.append(
f"\n{tag} <b>{res['name']}</b>({code}) — "
f"{res['recommendation']} {res['conviction']}/5\n"
f" {res['thesis'][:200]}\n"
f" 🎯 목표 {res.get('target_price', 0):,}원 / "
f"손절 {res.get('stop_loss', 0):,}"
)
except Exception as e:
logger.warning("new_buy.deep_err", code=code, error=str(e))
if len(lines) > 1:
await send_telegram("\n".join(lines))
logger.info("new_buy.notified", count=len(new_codes), analyzed=min(5, len(new_codes)))
except Exception as e:
logger.warning("new_buy.err", error=str(e))
# 텔레그램 알림 (notify=True 정기 1회만 — 매 호출 발신 방지)
if notify and (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
# ── 정기 브리핑 ───────────────────────────────────────────
_last_briefing_sent: datetime | None = None # 중복/폭주 차단 쓰로틀
async def send_briefing():
"""정기 시황 브리핑 — 종목당 1카드(매매가/포지션/재무/근거 통합).
30분 내 재호출은 무시 — n8n 중복/재시도 폭주로 같은 브리핑이 여러 건 발송되는 것 방지."""
global _last_briefing_sent
now = datetime.now()
if _last_briefing_sent and (now - _last_briefing_sent).total_seconds() < 1800:
logger.info("briefing.throttled", since=(now - _last_briefing_sent).total_seconds())
return
_last_briefing_sent = 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=["*"])
async def _daily_score_notify():
"""16:30 정기 일간리포트 — 코루틴 함수로 등록해야 APScheduler가 await함
(lambda로 감싸면 coroutine never awaited 버그)."""
await calculate_daily_scores(notify=True)
async def _learn_weights_job():
await learn_weights(days=90, segment="all")
async def _learn_pricing_job():
await learn_pricing(days=90, segment="all", target="return_7d", n_folds=5)
@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(_daily_score_notify, "cron",
day_of_week="mon-fri", hour=16, minute=30,
id="daily_score", replace_existing=True)
# 텔레그램 정기 알림 하루 2회로 축소 (사용자 요청 2026-06-02):
# 08:00 morning_brief_job(아침 브리핑) + 16:30 일간리포트(calculate_daily_scores).
# 중복이던 08:00 send_briefing + 12:30 send_briefing 스케줄 제거.
# (send_briefing 함수·/briefing/send 수동 엔드포인트는 유지 — 자동 발신만 중단)
# 데이터 정리: 매일 새벽 4시
scheduler.add_job(cleanup_old_data, "cron",
hour=4, minute=0,
id="cleanup", replace_existing=True)
# 데이터 무결성 모니터: 평일 10~17시 매시간 (RED면 텔레그램 경고, 3h 쓰로틀)
scheduler.add_job(data_health_monitor_job, "cron",
day_of_week="mon-fri", hour="10-17", minute=5,
id="data_health", replace_existing=True)
# 정확도 검증 리포트: 매주 일요일 10시 (방식이 실측 대비 맞는지)
scheduler.add_job(accuracy_report_job, "cron",
day_of_week="sun", hour=10, minute=0,
id="accuracy_report", replace_existing=True)
# 핫종목 검증팀: 평일 09:35 검증통과 핫종목 1회 보고 (dry-run)
scheduler.add_job(hot_validate_report_job, "cron",
day_of_week="mon-fri", hour=9, minute=35,
id="hot_validate", replace_existing=True)
# CIO 오늘의 결정안: 평일 09:20 생성+보고 (dry-run, 자동실행 OFF)
scheduler.add_job(cio_decisions_job, "cron",
day_of_week="mon-fri", hour=9, minute=20,
id="cio_decisions", replace_existing=True)
# 결정안 성과 채점: 매일 18:10 (성과가격 갱신 18:00 이후)
scheduler.add_job(verify_decisions_job, "cron",
hour=18, minute=10,
id="verify_decisions", 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(_learn_weights_job, "cron",
day_of_week="sun", hour=4, minute=0,
id="learn_weights", replace_existing=True)
scheduler.add_job(_learn_pricing_job, "cron",
day_of_week="sun", hour=5, minute=0,
id="learn_pricing", replace_existing=True)
# AI 심층분석 — 비용 폭주로 자동 호출 임시 비활성화 (2026-05-29)
# thinking 토큰 비용 누락 발견 → 실비용 추정의 5~10배. /deep 수동 호출만 사용
# 복원 방법: 아래 add_job 주석 해제. 또는 POST /deep-analysis/batch 수동.
# scheduler.add_job(deep_analysis_batch_job, "cron",
# day_of_week="mon-fri", hour=17, minute=0,
# id="deep_batch", replace_existing=True)
# 매일 아침 08:00 브리핑 — 톱 추천 + 보유/관심 종목 hybrid 분석 → 텔레그램
scheduler.add_job(morning_brief_job, "cron",
day_of_week="mon-fri", hour=8, minute=0,
id="morning_brief", replace_existing=True)
# 매일 03:30 사후 검증 — 30일 전 분석들 실제 수익률 매칭 → 라벨링
scheduler.add_job(verify_predictions_job, "cron",
hour=3, minute=30,
id="verify_predictions", replace_existing=True)
# 매주 일요일 09:00 — 주간 성과 리포트 (등급별 승률·알파·MDD)
scheduler.add_job(weekly_performance_report, "cron",
day_of_week="sun", hour=9, minute=0,
id="weekly_report", replace_existing=True)
# 매월 1일 09:30 — LLM 정확도 비교 리포트 (Gemini vs EXAONE)
scheduler.add_job(monthly_llm_comparison, "cron",
day=1, hour=9, minute=30,
id="monthly_llm_report", replace_existing=True)
# 자동매매: 평일 9:05~15:00 매 5분 신호 스캔 (매수+매도+한도 체크)
scheduler.add_job(auto_trade_scan_job, "cron",
day_of_week="mon-fri", hour="9-14", minute="*/5",
id="auto_trade_scan", replace_existing=True)
# 매일 16:00 보유종목 평가손익 갱신
scheduler.add_job(update_daily_pnl_job, "cron",
day_of_week="mon-fri", hour=16, minute=0,
id="daily_pnl", replace_existing=True)
# 매 30분 — 만료된 pending 주문 자동 expired 처리
scheduler.add_job(expire_stale_orders_job, "cron",
minute="*/30",
id="expire_stale_orders", replace_existing=True)
# 사후 검증 피드백 루프: 매일 03:00 — catalyst×horizon 신뢰도 / 출처 신뢰도 갱신
# (T-8d ~ T-120d 분석된 뉴스로 측정 → calc_news_score_weighted가 다음날부터 자동 적용)
scheduler.add_job(calibrate_sentiment_reliability, "cron",
hour=3, minute=0,
id="calibrate_reliability", replace_existing=True)
scheduler.add_job(calibrate_source_credibility, "cron",
hour=3, minute=20,
id="calibrate_source", replace_existing=True)
scheduler.start()
# catchup도 비용 폭주 이슈로 일시 비활성화 (2026-05-29)
# 복원: 아래 코드 주석 해제
# async def _deep_batch_catchup():
# now = datetime.now()
# if now.weekday() >= 5 or now.hour < 17: 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")
# pricing_model_v2: 120일 이상 된 모델 삭제 (model_blob 누적 방지, 최신만 사용)
await conn.execute(
"DELETE FROM pricing_model_v2 WHERE model_date < CURRENT_DATE - 120")
logger.info("cleanup.done",
prices=deleted_prices, recs=deleted_recs,
news=deleted_news, signals=deleted_signals)
# ── 데이터 무결성 모니터 ("데이터 제대로 쌓이는지" 상시 감시) ──────────
_last_health_alert: dict = {} # 동일 결함 반복경고 방지 (3시간 쓰로틀)
async def check_data_health() -> dict:
"""핵심 데이터 신선도·커버리지 점검 → 항목별 GREEN/YELLOW/RED."""
checks = []
def add(name, status, detail): checks.append({"name": name, "status": status, "detail": detail})
async with pg_pool.acquire() as c:
n = await c.fetchval("SELECT COUNT(DISTINCT stock_code) FROM stock_prices WHERE collected_at::date=CURRENT_DATE") or 0
add("시세(stock_prices)", "GREEN" if n>=2000 else "YELLOW" if n>=500 else "RED", f"오늘 {n}종목")
last_dt = await c.fetchval("SELECT MAX(dt) FROM stock_ohlcv WHERE stock_code<>'KOSPI'")
ocnt = await c.fetchval("SELECT COUNT(DISTINCT stock_code) FROM stock_ohlcv WHERE dt=$1", last_dt) if last_dt else 0
oage = (date.today()-last_dt).days if last_dt else 999
add("일봉(stock_ohlcv)", "GREEN" if (ocnt>=2000 and oage<=4) else "YELLOW" if ocnt>=1000 else "RED", f"{last_dt} {ocnt}종목")
n = await c.fetchval("SELECT COUNT(*) FROM stock_technical WHERE analyzed_at::date=CURRENT_DATE") or 0
add("기술분석(TA)", "GREEN" if n>=1500 else "YELLOW" if n>=300 else "RED", f"오늘 {n}")
n = await c.fetchval("SELECT COUNT(*) FROM stock_scores WHERE score_date=CURRENT_DATE") or 0
add("점수(stock_scores)", "GREEN" if n>=1000 else "YELLOW" if n>=100 else "RED", f"오늘 {n}종목")
n = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE analyzed_at > now()-interval '24 hours'") or 0
add("뉴스(24h)", "GREEN" if n>=100 else "YELLOW" if n>=10 else "RED", f"{n}")
kd = await c.fetchval("SELECT MAX(dt) FROM stock_ohlcv WHERE stock_code='KOSPI'")
kage = (date.today()-kd).days if kd else 999
add("KOSPI지수 일봉", "GREEN" if kage<=4 else "RED", f"{kd}")
# OHLCV 이상치: 한국 일일 가격제한 ±30% 초과는 정의상 불량(스케일버그·권리락 미조정).
# 35% 마진으로 정상 상한가(±30%)는 제외. 데이터품질 경고라 YELLOW, 급증 시만 RED.
bad = await c.fetchval("""
WITH px AS (
SELECT close_price, LAG(close_price) OVER (PARTITION BY stock_code ORDER BY dt) AS prev_close
FROM stock_ohlcv WHERE dt > CURRENT_DATE - 7 AND stock_code<>'KOSPI'
)
SELECT COUNT(*) FROM px
WHERE close_price > 0 AND prev_close > 0 AND abs(close_price::float/prev_close - 1) > 0.35
""") or 0
add("OHLCV 이상치(±30%위반)", "GREEN" if bad==0 else "YELLOW" if bad<=50 else "RED", f"최근7일 {bad}")
worst = "RED" if any(x["status"]=="RED" for x in checks) else ("YELLOW" if any(x["status"]=="YELLOW" for x in checks) else "GREEN")
return {"overall": worst, "checks": checks, "checked_at": datetime.now().isoformat()}
async def data_health_monitor_job():
"""평일 장중~마감후 점검. RED 있으면 텔레그램 경고 1건 (3시간 쓰로틀)."""
try:
h = await check_data_health()
except Exception as e:
logger.error("data_health.err", error=str(e)); return
reds = [x for x in h["checks"] if x["status"]=="RED"]
if not reds: return
key = ",".join(sorted(x["name"] for x in reds))
now = datetime.now()
last = _last_health_alert.get(key)
if last and (now-last).total_seconds() < 10800: return
_last_health_alert[key] = now
lines = ["🚨 <b>데이터 무결성 경고</b>"] + [f"{x['name']}: {x['detail']}" for x in reds]
lines.append("\n수집/분석 파이프라인 점검 필요")
await send_telegram("\n".join(lines))
logger.info("data_health.alert", reds=len(reds))
@app.get("/data-health")
async def data_health_endpoint():
return await check_data_health()
# ── 정확도 검증 하베스트 ("방식이 맞는지" 실측 대비) ──────────────────
async def compute_accuracy(days: int = 90) -> dict:
"""추천 등급별 사후 정확도. recommendation_performance(실측 7d/30d 수익률·알파) 집계.
동전주 폭등·불량 가격데이터 이상치가 평균(mean)을 왜곡하므로 중앙값(median)으로 집계.
매수계열 알파>0 & 매도계열 알파<0 이면 방식 유효."""
async with pg_pool.acquire() as conn:
grades = await conn.fetch("""
SELECT recommendation rec, COUNT(*) n,
percentile_cont(0.5) WITHIN GROUP (ORDER BY return_7d) ret7,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_7d) a7,
percentile_cont(0.5) WITHIN GROUP (ORDER BY return_30d) ret30,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_30d) a30,
AVG(CASE WHEN return_7d>0 THEN 1.0 ELSE 0 END) up7
FROM recommendation_performance
WHERE return_7d IS NOT NULL AND rec_date >= CURRENT_DATE - ($1::int)
GROUP BY recommendation
""", days)
pooled = await conn.fetchrow("""
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_7d)
FILTER (WHERE recommendation IN ('강력매수','매수관심')) buy_a7,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_7d)
FILTER (WHERE recommendation IN ('강력매도','매도관심')) sell_a7,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_30d)
FILTER (WHERE recommendation IN ('강력매수','매수관심')) buy_a30,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_30d)
FILTER (WHERE recommendation IN ('강력매도','매도관심')) sell_a30,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_30d)
FILTER (WHERE recommendation='강력매수') sb_a30,
percentile_cont(0.5) WITHIN GROUP (ORDER BY alpha_30d)
FILTER (WHERE recommendation='강력매도') ss_a30
FROM recommendation_performance
WHERE return_7d IS NOT NULL AND rec_date >= CURRENT_DATE - ($1::int)
""", days)
order = {"강력매수": 0, "매수관심": 1, "관망": 2, "매도관심": 3, "강력매도": 4}
rows = sorted([dict(g) for g in grades], key=lambda x: order.get(x["rec"], 9))
def rnd(v): return round(v, 2) if v is not None else None
buy_a, sell_a = rnd(pooled["buy_a7"]), rnd(pooled["sell_a7"])
buy_a30, sell_a30 = rnd(pooled["buy_a30"]), rnd(pooled["sell_a30"])
sb30, ss30 = rnd(pooled["sb_a30"]), rnd(pooled["ss_a30"])
spread30 = round(sb30 - ss30, 2) if (sb30 is not None and ss30 is not None) else None
# 판정은 30일 기준(가치투자 시계열·이상치 robust median). 7일은 단기 노이즈라 참고용.
if buy_a30 is None or sell_a30 is None or spread30 is None:
verdict = "30일 표본 부족 — 7일 참고"
elif buy_a30 > 0 and sell_a30 < 0 and spread30 >= 5:
verdict = "양호 (30일 매수>0·매도<0·스프레드≥5%p)"
elif spread30 >= 5 and sell_a30 < 0:
verdict = "부분유효 (강력매수 변별 양호, 매수계열 알파 음전)"
else:
verdict = "교정필요 (30일 변별력 부족)"
return {
"days": days,
"agg": "median",
"basis": "30d",
"grades": [{"rec": r["rec"], "n": r["n"],
"ret7": round(r["ret7"] or 0, 2), "alpha7": round(r["a7"] or 0, 2),
"ret30": round(r["ret30"], 2) if r["ret30"] is not None else None,
"alpha30": round(r["a30"], 2) if r["a30"] is not None else None,
"up7_pct": round(100 * (r["up7"] or 0))} for r in rows],
"buy_alpha7": buy_a, "sell_alpha7": sell_a,
"buy_alpha30": buy_a30, "sell_alpha30": sell_a30, "spread30": spread30,
"verdict": verdict,
}
@app.get("/accuracy")
async def accuracy_endpoint(days: int = Query(default=90, ge=7, le=365)):
return await compute_accuracy(days)
async def accuracy_report_job():
"""주간 정확도 리포트 — 방식이 실측 대비 맞는지 텔레그램 보고."""
try:
a = await compute_accuracy(90)
except Exception as e:
logger.error("accuracy.err", error=str(e)); return
lines = ["📈 <b>추천 정확도 리포트 (최근90일·30일 알파·중앙값)</b>",
f"판정: <b>{a['verdict']}</b>",
f"매수계열 알파 {a['buy_alpha30']} / 매도계열 알파 {a['sell_alpha30']} / 강력매수−강력매도 스프레드 {a['spread30']}%p\n"]
for g in a["grades"]:
lines.append(f"{g['rec']}: n{g['n']} 30일수익{g['ret30']}% 알파{g['alpha30']}% (7일알파{g['alpha7']}%)")
await send_telegram("\n".join(lines))
logger.info("accuracy.report.sent", verdict=a["verdict"])
# ── 🔥 핫종목 검증팀 (키움 핫 × 가치/품질 노이즈필터, dry-run) ──────────
async def compute_hot_validate(top: int = 30) -> dict:
"""키움 거래량급증(ka10023) × 가치/품질 필터 → 핫한데 가치없는 노이즈
(ETF·파생·작전·적자·분식·음수점수) 제거 → '검증통과'만 추림."""
hot = []
try:
async with httpx.AsyncClient() as cli:
r = await cli.get("http://kis-api:8585/volume-surge", timeout=15)
hot = ((r.json() or {}).get("data") or [])[:top]
except Exception as e:
logger.warning("hot_validate.fetch_err", error=str(e))
return {"error": "키움 핫종목 조회 실패", "detail": str(e)}
codes = [h["code"] for h in hot if h.get("code")]
if not codes:
return {"hot_count": 0, "passed": [], "noise": [], "watch": []}
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT h.code, c.corp_name, c.is_active,
sc.total_score, sc.recommendation, sc.beneish_score,
pr.market_cap, fin.operating_profit
FROM unnest($1::text[]) AS h(code)
LEFT JOIN dart_corps c ON c.stock_code = h.code
LEFT JOIN LATERAL (SELECT total_score, recommendation, beneish_score
FROM stock_scores WHERE stock_code=h.code
ORDER BY score_date DESC LIMIT 1) sc ON true
LEFT JOIN LATERAL (SELECT market_cap FROM stock_prices WHERE stock_code=h.code
ORDER BY collected_at DESC LIMIT 1) pr ON true
LEFT JOIN LATERAL (SELECT operating_profit FROM dart_financials
WHERE stock_code=h.code AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 1) fin ON true
""", codes)
by = {r["code"]: r for r in rows}
passed, noise, watch = [], [], []
for h in hot:
code = h.get("code"); r = by.get(code)
it = {"code": code, "name": (r["corp_name"] if r and r["corp_name"] else h.get("name", "")),
"change_pct": h.get("change_pct"), "surge_rate": h.get("surge_rate"),
"score": round(r["total_score"], 1) if r and r["total_score"] is not None else None,
"reco": r["recommendation"] if r else None}
if not r or not r["is_active"]:
it["verdict"], it["reason"] = "노이즈", "ETF/파생/비상장(가치판단 불가)"; noise.append(it); continue
mc, op, sc, bn = r["market_cap"] or 0, r["operating_profit"], r["total_score"], r["beneish_score"]
if mc and mc < 10_000_000_000:
it["verdict"], it["reason"] = "노이즈", f"초소형 시총{mc//100000000}억(작전위험)"; noise.append(it); continue
if op is not None and op <= 0:
it["verdict"], it["reason"] = "노이즈", "영업적자"; noise.append(it); continue
if bn is not None and bn >= 50:
it["verdict"], it["reason"] = "노이즈", f"분식의심(Beneish {bn:.0f})"; noise.append(it); continue
if sc is None:
it["verdict"], it["reason"] = "관찰", "점수 미산출"; watch.append(it); continue
if sc < 0:
it["verdict"], it["reason"] = "노이즈", f"투자부적합(점수{sc:.0f})"; noise.append(it); continue
if sc >= 40 and r["recommendation"] in ("강력매수", "매수관심"):
it["verdict"], it["reason"] = "검증통과", f"가치+모멘텀(점수{sc:.0f}·{r['recommendation']})"; passed.append(it); continue
it["verdict"], it["reason"] = "관찰", f"중립(점수{sc:.0f})"; watch.append(it)
return {"hot_count": len(hot), "passed_count": len(passed),
"noise_count": len(noise), "watch_count": len(watch),
"noise_filtered_pct": round(100 * len(noise) / len(hot)) if hot else 0,
"passed": passed, "watch": watch, "noise": noise}
@app.get("/hot/validate")
async def hot_validate_endpoint(top: int = Query(default=30, ge=5, le=50)):
return await compute_hot_validate(top)
async def hot_validate_report_job():
"""평일 09:35 — 검증통과 핫종목만 텔레그램 보고 (dry-run, 1일 1회)."""
try:
v = await compute_hot_validate(30)
except Exception as e:
logger.error("hot_validate.err", error=str(e)); return
if v.get("error") or not v.get("passed"):
return
lines = [f"🔥 <b>검증통과 핫종목</b> (키움 핫 {v['hot_count']}개 중 노이즈 {v['noise_filtered_pct']}% 제거)"]
for p in v["passed"][:10]:
lines.append(f"{p['name']}({p['code']}) {p['change_pct']:+.1f}% — {p['reason']}")
await send_telegram("\n".join(lines))
logger.info("hot_validate.report.sent", passed=v["passed_count"])
# ── 🧭 CIO 종합 에이전트 (오늘의 결정안, dry-run) ──────────────────
def _conviction(score: float, votes: int) -> int:
if score >= 70 and votes >= 3: return 5
if score >= 55 and votes >= 2: return 4
if score >= 45: return 3
if score >= 35: return 2
return 1
async def run_cio_decisions(top: int = 8) -> dict:
"""점수+보팅+리스크+핫검증 종합 → 오늘의 매수/매도 결정안 (dry-run, 승인 전).
매수=추천 상위 후보, 매도=보유종목(user_portfolio) 등급악화·손절. entry_price 기록(사후추적)."""
today = date.today()
async with pg_pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS daily_decisions (
id SERIAL PRIMARY KEY,
decision_date DATE NOT NULL,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
action VARCHAR(20) NOT NULL,
conviction INTEGER DEFAULT 0,
size_pct FLOAT DEFAULT 0,
total_score FLOAT,
thesis TEXT DEFAULT '',
risk_notes TEXT DEFAULT '',
status VARCHAR(20) DEFAULT 'proposed',
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(decision_date, stock_code)
)
""")
for col in ("entry_price BIGINT DEFAULT 0", "return_7d FLOAT",
"kospi_return_7d FLOAT", "alpha_7d FLOAT", "outcome VARCHAR(10)"):
await conn.execute(f"ALTER TABLE daily_decisions ADD COLUMN IF NOT EXISTS {col}")
regime_label, _ = await calc_market_regime(conn)
hotv = await compute_hot_validate(30)
hot_pass = {p["code"] for p in (hotv.get("passed") or [])} if not hotv.get("error") else set()
async def _cur_price(code):
return await conn.fetchval(
"SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", code)
saved = []
# ── 매수 결정안 (추천 상위 후보) ──
cands = await conn.fetch("""
SELECT s.stock_code, COALESCE(d.corp_name, s.stock_code) name,
s.total_score, s.recommendation, s.buy_votes,
s.position_size_pct, s.top_reasons, s.beneish_score, s.earnings_quality
FROM stock_scores s JOIN dart_corps d ON d.stock_code = s.stock_code
WHERE s.score_date = $1 AND d.is_active = true
AND s.recommendation IN ('강력매수', '매수관심') AND s.buy_votes >= 1
ORDER BY s.total_score DESC LIMIT $2
""", today, top)
for c in cands:
conv = _conviction(c["total_score"] or 0, c["buy_votes"] or 0)
if c["stock_code"] in hot_pass: conv = min(5, conv + 1)
size = round(c["position_size_pct"] or (conv * 2.0), 1)
if regime_label == "약세": size = round(size * 0.5, 1)
risk = []
if regime_label == "약세": risk.append("시장 약세(사이즈↓)")
if (c["beneish_score"] or 0) >= 50: risk.append("분식의심")
if c["earnings_quality"] is not None and c["earnings_quality"] < 0: risk.append("이익품질 낮음")
if c["stock_code"] in hot_pass: risk.append("핫종목 검증통과")
ep = await _cur_price(c["stock_code"]) or 0
await conn.execute("""
INSERT INTO daily_decisions
(decision_date,stock_code,stock_name,action,conviction,size_pct,
total_score,thesis,risk_notes,entry_price,status)
VALUES ($1,$2,$3,'매수',$4,$5,$6,$7,$8,$9,'proposed')
ON CONFLICT (decision_date,stock_code) DO UPDATE SET
action='매수',conviction=$4,size_pct=$5,total_score=$6,
thesis=$7,risk_notes=$8,entry_price=$9
""", today, c["stock_code"], c["name"], conv, size,
c["total_score"], (c["top_reasons"] or "")[:300], " · ".join(risk), int(ep))
saved.append({"code": c["stock_code"], "name": c["name"], "action": "매수",
"conviction": conv, "size_pct": size,
"score": round(c["total_score"], 1) if c["total_score"] is not None else None,
"risk": " · ".join(risk)})
# ── 매도 결정안 (보유종목 등급악화/손절) ──
holds = await conn.fetch("""
SELECT p.stock_code, p.stock_name, p.buy_price,
s.total_score, s.recommendation, s.sell_votes, s.top_reasons
FROM user_portfolio p
LEFT JOIN stock_scores s ON s.stock_code=p.stock_code AND s.score_date=$1
WHERE p.active = true
""", today)
for h in holds:
cp = await _cur_price(h["stock_code"]) or 0
loss = ((cp - h["buy_price"]) / h["buy_price"] * 100) if (h["buy_price"] and cp) else 0
reco = h["recommendation"]; reasons = []; sell = False
if reco in ("강력매도", "매도관심"): sell = True; reasons.append(f"등급 {reco}")
if loss <= -8: sell = True; reasons.append(f"손절({loss:.0f}%)")
if (h["sell_votes"] or 0) >= 3: sell = True; reasons.append(f"매도보팅{h['sell_votes']}")
if not sell: continue
conv = 5 if (reco == "강력매도" or loss <= -12) else 3
await conn.execute("""
INSERT INTO daily_decisions
(decision_date,stock_code,stock_name,action,conviction,size_pct,
total_score,thesis,risk_notes,entry_price,status)
VALUES ($1,$2,$3,'매도',$4,0,$5,$6,$7,$8,'proposed')
ON CONFLICT (decision_date,stock_code) DO UPDATE SET
action='매도',conviction=$4,total_score=$5,thesis=$6,risk_notes=$7,entry_price=$8
""", today, h["stock_code"], h["stock_name"], conv,
h["total_score"], (h["top_reasons"] or "")[:200], " · ".join(reasons), int(cp))
saved.append({"code": h["stock_code"], "name": h["stock_name"], "action": "매도",
"conviction": conv, "size_pct": 0,
"score": round(h["total_score"], 1) if h["total_score"] is not None else None,
"risk": " · ".join(reasons)})
return {"decision_date": str(today), "regime": regime_label, "count": len(saved),
"buy": sum(1 for x in saved if x["action"] == "매수"),
"sell": sum(1 for x in saved if x["action"] == "매도"),
"decisions": saved}
@app.post("/decisions/generate")
async def decisions_generate(top: int = Query(default=8, ge=1, le=20)):
return await run_cio_decisions(top)
@app.get("/decisions")
async def decisions_get():
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT stock_code, stock_name, action, conviction, size_pct, total_score,
thesis, risk_notes, status
FROM daily_decisions WHERE decision_date = CURRENT_DATE
ORDER BY conviction DESC, total_score DESC
""")
return [dict(r) for r in rows]
async def verify_decisions_job():
"""결정안 7일 성과 채점 (return/alpha/정답여부) — '회사 결정이 실제 맞았나'. 매일 18:10."""
scored = 0
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, stock_code, action, entry_price, decision_date
FROM daily_decisions
WHERE return_7d IS NULL AND entry_price > 0
AND decision_date <= CURRENT_DATE - 7 AND decision_date >= CURRENT_DATE - 60
""")
for r in rows:
target = r["decision_date"] + timedelta(days=7)
if target > date.today(): continue
price = await _close_near(conn, r["stock_code"], target)
if price is None: continue
ret = (price - r["entry_price"]) / r["entry_price"] * 100
kret = await _kospi_return_between(conn, r["decision_date"], target)
alpha = (ret - kret) if kret is not None else None
outcome = None
if alpha is not None:
outcome = ("정답" if alpha > 0 else "오답") if r["action"] == "매수" \
else ("정답" if alpha < 0 else "오답")
await conn.execute("""UPDATE daily_decisions
SET return_7d=$1, kospi_return_7d=$2, alpha_7d=$3, outcome=$4 WHERE id=$5""",
ret, kret, alpha, outcome, r["id"])
scored += 1
logger.info("verify_decisions.done", scored=scored)
@app.get("/decisions/accuracy")
async def decisions_accuracy(days: int = Query(default=60, ge=7, le=365)):
"""CIO 결정안의 실측 성과 — 자동실행 게이트."""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT action, COUNT(*) n, AVG(return_7d) ret, AVG(alpha_7d) alpha,
AVG(CASE WHEN outcome='정답' THEN 1.0 ELSE 0 END) hit
FROM daily_decisions
WHERE return_7d IS NOT NULL AND decision_date >= CURRENT_DATE - ($1::int)
GROUP BY action
""", days)
return {"days": days, "by_action": [
{"action": r["action"], "n": r["n"],
"avg_return7": round(r["ret"] or 0, 2), "avg_alpha7": round(r["alpha"] or 0, 2),
"hit_rate": round(100 * (r["hit"] or 0))} for r in rows]}
async def cio_decisions_job():
"""평일 09:20 — CIO 오늘의 결정안 생성 + 텔레그램 보고 (dry-run, 자동실행 OFF)."""
try:
d = await run_cio_decisions(8)
except Exception as e:
logger.error("cio.err", error=str(e)); return
if not d.get("decisions"): return
lines = [f"🧭 <b>오늘의 결정안</b> ({d['decision_date']}, 시장:{d['regime']}) — dry-run"]
for x in d["decisions"]:
lines.append(f"{''*x['conviction']} <b>{x['name']}</b>({x['code']}) {x['action']} "
f"비중{x['size_pct']}% (점수{x['score']})"
+ (f"\n ⚠️{x['risk']}" if x['risk'] else ""))
lines.append("\n(자동실행 OFF — 검증 단계. 정확도 양전환 시 실행 연결)")
await send_telegram("\n".join(lines))
logger.info("cio.report.sent", count=d["count"])
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/score/calculate")
async def manual_calc(notify: bool = Query(default=False)):
n = await calculate_daily_scores(notify=notify)
return {"status": "done", "scored": n}
_BACKFILL_STATE: dict = {"running": False, "current": None, "done_days": 0,
"total_days": 0, "started_at": None, "errors": []}
@app.post("/score/backfill")
async def score_backfill(start_date: str = Query(...), end_date: str = Query(...),
skip_existing: bool = Query(default=True),
force: bool = Query(default=False)):
"""과거 시점 score 백필 — look-ahead bias 차단 모드로 calculate_daily_scores 반복 호출.
영업일(월~금)만 순회. skip_existing=True면 이미 stock_scores에 있는 날짜 건너뜀.
force=True면 동시 실행 잠금 무시 (위험).
예: POST /score/backfill?start_date=2025-06-01&end_date=2026-04-30
백그라운드 실행, 진행률은 GET /score/backfill/status 로 확인.
"""
if _BACKFILL_STATE["running"] and not force:
return {"status": "already_running", "state": _BACKFILL_STATE}
try:
s = datetime.strptime(start_date, "%Y-%m-%d").date()
e = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
return {"status": "error", "msg": "start_date/end_date 형식 YYYY-MM-DD"}
if s > e:
return {"status": "error", "msg": "start_date > end_date"}
if e >= date.today():
return {"status": "error", "msg": "end_date는 오늘 이전이어야 함 (오늘은 운영 score가 처리)"}
# 영업일만 (월~금, 한국 공휴일 무시 — score는 휴일 데이터 자연스럽게 비어있음)
days: list[date] = []
d = s
while d <= e:
if d.weekday() < 5: # 월=0 ~ 금=4
days.append(d)
d += timedelta(days=1)
if skip_existing:
async with pg_pool.acquire() as conn:
existing = await conn.fetch(
"SELECT DISTINCT score_date FROM stock_scores "
"WHERE score_date BETWEEN $1 AND $2", s, e)
existing_set = {r["score_date"] for r in existing}
days = [d for d in days if d not in existing_set]
if not days:
return {"status": "nothing_to_do", "msg": "백필할 영업일 없음 (이미 score 있음)"}
_BACKFILL_STATE.update({
"running": True, "current": None, "done_days": 0,
"total_days": len(days), "started_at": datetime.now().isoformat(),
"errors": [], "range": f"{s} ~ {e}",
})
async def run():
try:
for d in days:
_BACKFILL_STATE["current"] = str(d)
try:
await calculate_daily_scores(as_of=d)
except Exception as ex:
_BACKFILL_STATE["errors"].append({"date": str(d), "error": str(ex)[:200]})
logger.error("score.backfill.day_err", date=str(d), error=str(ex))
_BACKFILL_STATE["done_days"] += 1
logger.info("score.backfill.done", days=len(days),
errors=len(_BACKFILL_STATE["errors"]))
finally:
_BACKFILL_STATE["running"] = False
_BACKFILL_STATE["current"] = None
asyncio.create_task(run())
return {"status": "started", "days": len(days), "from": str(days[0]), "to": str(days[-1])}
@app.get("/score/backfill/status")
async def score_backfill_status():
"""백필 진행 상태 + 에러 요약."""
return _BACKFILL_STATE
@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("/hot")
async def hot_stocks(limit: int = Query(default=20)):
"""지금 뜨는 종목 — 뉴스 모멘텀(최근3일 호재강도) + 단기 가격 모멘텀(5일) + 거래량 급증.
가치투자 추천(/ranking)과 독립된 '최신 트렌드' 렌즈. 가치 점수는 참고용으로 병기."""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
WITH active AS (
SELECT stock_code FROM dart_corps WHERE is_active=true
),
px AS (
SELECT o.stock_code,
(array_agg(o.close_price ORDER BY o.dt DESC))[1] AS last_close,
(array_agg(o.close_price ORDER BY o.dt DESC))[6] AS close_5d,
(array_agg(o.volume ORDER BY o.dt DESC))[1]::float AS last_vol,
AVG(o.volume)::float AS avg_vol
FROM stock_ohlcv o
WHERE o.stock_code IN (SELECT stock_code FROM active)
AND o.dt >= CURRENT_DATE - 45
GROUP BY o.stock_code
),
nw AS (
SELECT primary_stock AS stock_code,
SUM(CASE WHEN sentiment='호재' THEN COALESCE(intensity,1)
WHEN sentiment='악재' THEN -COALESCE(intensity,1)
ELSE 0 END)::float AS news_pts,
COUNT(*) FILTER (WHERE sentiment='호재') AS pos_cnt,
COUNT(*) AS news_cnt
FROM news_analysis
WHERE primary_stock <> ''
AND published_at >= now() - interval '3 days'
GROUP BY primary_stock
)
SELECT a.stock_code,
COALESCE(t.stock_name, c.corp_name, a.stock_code) AS stock_name,
px.last_close, px.close_5d, px.last_vol, px.avg_vol,
t.vol_ratio,
COALESCE(nw.news_pts, 0) AS news_pts,
COALESCE(nw.pos_cnt, 0) AS pos_cnt,
COALESCE(nw.news_cnt, 0) AS news_cnt,
s.total_score, s.recommendation
FROM active a
LEFT JOIN px ON px.stock_code = a.stock_code
LEFT JOIN nw ON nw.stock_code = a.stock_code
LEFT JOIN stock_technical t ON t.stock_code = a.stock_code
LEFT JOIN dart_corps c ON c.stock_code = a.stock_code
LEFT JOIN stock_scores s
ON s.stock_code = a.stock_code
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
""")
out = []
for r in rows:
lc, c5 = r["last_close"], r["close_5d"]
price_mom = round((lc / c5 - 1) * 100, 1) if (lc and c5 and c5 > 0) else 0.0
vr = r["vol_ratio"]
if not vr or vr <= 0:
vr = (r["last_vol"] / r["avg_vol"]) if (r["last_vol"] and r["avg_vol"] and r["avg_vol"] > 0) else 1.0
news_pts = float(r["news_pts"] or 0)
c_news = max(0.0, min(80.0, news_pts * 5.0)) # 최근3일 호재강도합 ×5 (0~80, 상위 변별력 확보)
c_vol = max(0.0, min(20.0, (vr - 1.0) * 15.0)) # 평소 거래량 대비 배수 (0~20)
# 5일 가격 모멘텀은 백테스트상 되돌림(mean-reversion) 유발 → 점수서 제외, 표시만 유지.
# (news+vol 조합이 1일 알파 +0.58% vs 가격포함 +0.11%로 우월)
hot = round(c_news + c_vol, 1)
if hot <= 3: # 뉴스·거래량 둘 다 미미한 종목 제외
continue
out.append({
"stock_code": r["stock_code"],
"stock_name": r["stock_name"],
"hot_score": hot,
"news_mom": round(c_news, 1),
"price_5d_pct": price_mom,
"vol_ratio": round(vr, 1),
"pos_news_3d": int(r["pos_cnt"] or 0),
"value_score": round(r["total_score"], 1) if r["total_score"] is not None else None,
"value_reco": r["recommendation"],
})
out.sort(key=lambda x: x["hot_score"], reverse=True)
return out[:limit]
@app.get("/hot/backtest")
async def hot_backtest(days: int = Query(default=40), top_k: int = Query(default=10),
horizon: int = Query(default=5),
w_news: float = Query(default=5.0), w_price: float = Query(default=1.5),
w_vol: float = Query(default=15.0)):
"""핫점수 예측력 검증: 과거 각 거래일의 핫 상위 top_k 종목이 horizon 거래일 후 실제 수익률.
시장(전종목 평균) 대비 알파·승률·IC(스피어만)로 /hot 가중치가 유효한지 측정.
w_news/w_price/w_vol로 가중치 조합을 실험."""
import numpy as np
from collections import defaultdict
async with pg_pool.acquire() as conn:
ohlcv = await conn.fetch("""
WITH base AS (
SELECT o.stock_code, o.dt,
o.close_price::float AS c, o.volume::float AS v,
(LAG(o.close_price, 5) OVER w)::float AS c5,
(AVG(o.volume) OVER (PARTITION BY o.stock_code ORDER BY o.dt
ROWS BETWEEN 20 PRECEDING AND 1 PRECEDING))::float AS avgv20,
(LEAD(o.close_price, $1) OVER w)::float AS cfwd
FROM stock_ohlcv o
WHERE o.stock_code <> 'KOSPI'
AND o.dt >= CURRENT_DATE - ($2 + $1 + 45)
WINDOW w AS (PARTITION BY o.stock_code ORDER BY o.dt)
)
SELECT stock_code, dt, c, v, c5, avgv20, cfwd
FROM base
WHERE dt >= CURRENT_DATE - ($2 + $1)
AND c5 IS NOT NULL AND c5 > 0 AND cfwd IS NOT NULL AND avgv20 > 0
ORDER BY dt
""", horizon, days)
news = await conn.fetch("""
SELECT primary_stock AS code, published_at::date AS d,
SUM(CASE WHEN sentiment='호재' THEN COALESCE(intensity,1)
WHEN sentiment='악재' THEN -COALESCE(intensity,1) ELSE 0 END)::float AS pts
FROM news_analysis
WHERE primary_stock <> '' AND published_at >= CURRENT_DATE - ($1 + 45)
GROUP BY primary_stock, published_at::date
""", days)
npts = {(r["code"], r["d"]): r["pts"] for r in news}
def news3(code, d):
return sum(npts.get((code, d - timedelta(days=k)), 0.0) for k in range(3))
byday = defaultdict(list)
pooled_hot, pooled_fwd = [], []
for r in ohlcv:
c, c5, cfwd, avgv, v = r["c"], r["c5"], r["cfwd"], r["avgv20"], r["v"]
price_mom = (c / c5 - 1) * 100
vr = (v / avgv) if avgv > 0 else 1.0
np_pts = news3(r["stock_code"], r["dt"])
c_news = max(0.0, min(80.0, np_pts * w_news))
c_price = max(-15.0, min(30.0, price_mom * w_price))
c_vol = max(0.0, min(20.0, (vr - 1.0) * w_vol))
hot = c_news + c_price + c_vol
fwd = (cfwd / c - 1) * 100
byday[r["dt"]].append((hot, fwd))
pooled_hot.append(hot); pooled_fwd.append(fwd)
topk_alphas, topk_rets, mkt_rets, wins, n_days = [], [], [], 0, 0
for d, lst in byday.items():
if len(lst) < top_k * 3:
continue
n_days += 1
lst.sort(key=lambda x: x[0], reverse=True)
topk = [f for _, f in lst[:top_k]]
mkt = float(np.mean([f for _, f in lst]))
tk = float(np.mean(topk))
topk_rets.append(tk); mkt_rets.append(mkt)
topk_alphas.append(tk - mkt)
if tk > mkt: wins += 1
ic = None
if len(pooled_hot) > 30:
try:
from scipy import stats as _ss
ic = round(float(_ss.spearmanr(pooled_hot, pooled_fwd).correlation), 4)
except Exception:
ic = None
if n_days == 0:
return {"error": "표본 부족", "n_obs": len(pooled_hot)}
avg_alpha = round(float(np.mean(topk_alphas)), 2)
return {
"params": {"days": days, "top_k": top_k, "horizon_days": horizon},
"n_days": n_days, "n_obs": len(pooled_hot),
"hot_topk_avg_return_pct": round(float(np.mean(topk_rets)), 2),
"market_avg_return_pct": round(float(np.mean(mkt_rets)), 2),
"alpha_pct": avg_alpha,
"win_rate_vs_market_pct": round(100.0 * wins / n_days, 1),
"ic_spearman": ic,
"verdict": ("핫점수 예측력 유효 (시장 초과)" if avg_alpha > 0.3 and (ic or 0) > 0.03
else "예측력 약함 — 가중치 재조정 필요" if avg_alpha <= 0
else "중립 — 미세 우위"),
}
@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,
}
# 한국주식 왕복 거래비용 추정(증권거래세+수수료+슬리피지). 백테스트 순수익률 보정용.
ROUND_TRIP_COST_PCT = 0.30
def _max_drawdown(seq: list) -> float:
"""시간순 거래 수익률(%) 리스트 → 누적 자산곡선 최대낙폭(%). 음수 반환."""
equity = peak = 1.0
mdd = 0.0
for r in seq:
equity *= (1 + r / 100.0)
if equity > peak:
peak = equity
elif peak > 0:
mdd = max(mdd, (peak - equity) / peak * 100.0)
return -round(mdd, 2)
@app.get("/backtest")
async def backtest(days: int = Query(default=180, ge=30, le=365)):
"""
M1: 과거 추천 종목의 7d/30d 수익률, KOSPI 대비 알파, 적중률, 샤프, MDD 산출.
수익률·알파는 왕복 거래비용(ROUND_TRIP_COST_PCT) 차감한 순(net) 기준.
MDD는 rec_date별 등비중 포트폴리오 자산곡선의 최대낙폭(동일자 추천은 병렬 보유).
"""
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
ORDER BY rec_date
""", since)
if not rows:
return {"period_days": days, "n": 0, "msg": "데이터 없음"}
cost = ROUND_TRIP_COST_PCT
def _date_curve(window_rows: list, col: str) -> list:
"""rec_date별 등비중 평균 net 수익률 → 시간순 자산곡선 입력."""
by_date: dict = {}
for r in window_rows:
v = r[col]
if v is not None:
by_date.setdefault(r["rec_date"], []).append(float(v) - cost)
return [sum(v) / len(v) for _, v in sorted(by_date.items())]
def _summary(net_returns: list, net_alphas: list, mdd: float) -> dict:
if not net_returns:
return {"n": 0}
n = len(net_returns)
avg_ret = sum(net_returns) / n
sd = (sum((r - avg_ret) ** 2 for r in net_returns) / n) ** 0.5 if n > 1 else 0
win = sum(1 for r in net_returns if r > 0) / n * 100
# 일간 변동성 가정 안 하고 단순 샤프 근사 (mean/sd, RFR=0)
sharpe = avg_ret / sd if sd > 0 else 0
avg_alpha = sum(net_alphas) / len(net_alphas) if net_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": mdd,
"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}"]) - cost for r in rows
if r[f"return_{window}"] is not None]
als = [float(r[f"alpha_{window}"]) - cost for r in rows
if r[f"alpha_{window}"] is not None]
mdd = _max_drawdown(_date_curve(rows, f"return_{window}"))
overall[window] = _summary(rs, als, mdd)
by_rec = {}
for rec in ("강력매수", "매수관심"):
sub = [r for r in rows if r["recommendation"] == rec]
rs7 = [float(r["return_7d"]) - cost for r in sub if r["return_7d"] is not None]
als7 = [float(r["alpha_7d"]) - cost for r in sub if r["alpha_7d"] is not None]
mdd7 = _max_drawdown(_date_curve(sub, "return_7d"))
by_rec[rec] = _summary(rs7, als7, mdd7)
# 자산곡선(7d): rec_date별 등비중 포트폴리오 vs KOSPI 누적 수익률(%)
ec_by_date: dict = {}
for r in rows:
if r["return_7d"] is not None:
ec_by_date.setdefault(r["rec_date"], []).append((
float(r["return_7d"]) - cost,
float(r["kospi_return_7d"]) if r["kospi_return_7d"] is not None else None))
equity_curve = []
s_eq = k_eq = 1.0
for d in sorted(ec_by_date):
day = ec_by_date[d]
kk = [x[1] for x in day if x[1] is not None]
s_eq *= (1 + (sum(x[0] for x in day) / len(day)) / 100.0)
k_eq *= (1 + (sum(kk) / len(kk) if kk else 0.0) / 100.0)
equity_curve.append({"date": str(d),
"strategy_pct": round((s_eq - 1) * 100, 2),
"kospi_pct": round((k_eq - 1) * 100, 2)})
return {
"period_days": days,
"total_recommendations": len(rows),
"round_trip_cost_pct": cost,
"overall": overall,
"by_recommendation_7d": by_rec,
"equity_curve": equity_curve,
}
@app.post("/performance/recompute")
async def m_recompute_performance():
"""기존 recommendation_performance 행을 OHLCV 종가로 재계산 (라벨 정정).
OHLCV 부재(보존기간 초과) 행은 skipped로 집계하고 기존 값 유지."""
return await update_performance_prices(force=True)
@app.post("/learn-weights")
async def learn_weights(days: int = Query(default=90, ge=14, le=365),
segment: str = Query(default="all")):
"""
백테스트 기반 공식별 가중치 학습.
각 공식이 '매수' 신호를 낸 종목들의 평균 7일 수익률 - '매도' 신호 종목 평균 = edge
edge가 큰 공식일수록 가중치 ↑ → ensemble 보팅에 반영.
segment: "all" | "regime:강세|중립|약세" | "sector:반도체|2차전지|..."
표본 크기로 edge 신뢰도 조정 (n<10이면 edge=0 처리).
"""
since = date.today() - timedelta(days=days)
formulas = ENSEMBLE_FORMULAS
async with pg_pool.acquire() as conn:
where = "p.rec_date >= $1 AND p.return_7d IS NOT NULL AND p.entry_price > 0"
params: list = [since]
if segment.startswith("regime:"):
params.append(segment.split(":", 1)[1])
where += f" AND EXISTS(SELECT 1 FROM market_regime mr WHERE mr.dt=s.score_date AND mr.regime=${len(params)})"
elif segment.startswith("sector:"):
params.append(segment.split(":", 1)[1])
where += f" AND s.sector = ${len(params)}"
rows = await conn.fetch(f"""
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 {where}
""", *params)
if not rows:
return {"period_days": days, "segment": segment, "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"]))
n_b, n_s = len(buy_rets), len(sell_rets)
avg_buy = sum(buy_rets)/n_b if n_b else 0.0
avg_sell = sum(sell_rets)/n_s if n_s else 0.0
raw_edge = avg_buy - avg_sell
# 표본 신뢰도 (shrinkage): n<10이면 edge=0, n>=30이면 풀가중. 사이는 선형
n_min = min(n_b, n_s) if (n_b and n_s) else max(n_b, n_s)
shrink = max(0.0, min(1.0, (n_min - 10) / 20.0))
edge = raw_edge * shrink
out[f] = {
"buy_n": n_b, "buy_avg_return_7d": round(avg_buy, 2),
"sell_n": n_s, "sell_avg_return_7d": round(avg_sell, 2),
"raw_edge": round(raw_edge, 2), "shrink": round(shrink, 2),
"edge": round(edge, 2),
}
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, segment, weights, period_days, sample_size)
VALUES (CURRENT_DATE, $1, $2, $3, $4)
ON CONFLICT (config_date, segment) DO UPDATE
SET weights=$2, period_days=$3, sample_size=$4
""", segment, json.dumps(weights), days, len(rows))
return {"period_days": days, "segment": segment, "sample": len(rows),
"by_formula": out, "weights": weights,
"applied": "다음 /score/calculate 부터 자동 적용 (segment 매칭 시)"}
@app.post("/calibrate/sentiment")
async def trigger_calibrate_sentiment():
"""catalyst × time_horizon별 사후 reliability 수동 갱신"""
return await calibrate_sentiment_reliability()
@app.post("/calibrate/source")
async def trigger_calibrate_source():
"""출처별 credibility 수동 갱신"""
return await calibrate_source_credibility()
@app.get("/calibrate/status")
async def calibrate_status():
"""학습된 catalyst×horizon reliability + 상위/하위 source credibility 조회"""
async with pg_pool.acquire() as conn:
sr = await conn.fetch(
"SELECT catalyst, time_horizon, sample_size, avg_return_3d, "
"hit_ratio_3d, reliability_score, last_updated "
"FROM sentiment_reliability ORDER BY reliability_score DESC")
sc = await conn.fetch(
"SELECT source, credibility, sample_size, hit_ratio_3d, avg_signed_return_3d "
"FROM news_source_credibility ORDER BY credibility DESC")
return {
"sentiment_reliability": [dict(r) for r in sr],
"source_credibility": [dict(r) for r in sc],
}
@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()}
# ── 학습 보강: walk-forward CV + 평가지표 + 레짐/섹터 분리 ──────────────
LEARN_FEATURE_NAMES = [
# 종합·앙상블 점수
"total_score", "magic_score", "f_score", "altman_z", "peg",
"momentum_pct", "beneish_score", "gpa_pct", "g_score",
"amihud_illiq", "market_beta",
# 채널별 점수
"news_score", "dart_score", "technical_score",
"foreign_score", "short_score", "price_score",
"us_overnight_adj",
# 펀더멘털·DCF·이익품질
"trend_score", "earnings_quality", "margin_of_safety",
# 감성·뉴스 모멘텀
"sentiment_momentum", "sentiment_alpha",
"attention_score", "news_surge_ratio",
# 변동성·레짐
"volatility_60d", "market_regime_adj",
]
def _row_features(r: dict) -> list[float]:
out = []
for fn in LEARN_FEATURE_NAMES:
try:
out.append(float(r[fn] if r[fn] is not None else 0))
except Exception:
out.append(0.0)
return out
async def _fetch_training_rows(conn, since: date, segment: str = "all"):
"""
학습용 데이터: stock_scores 전체 이력 × stock_ohlcv 실현수익률.
(구버전은 recommendation_performance=추천종목만 → 표본 수십건·선택편향.
현재는 전 종목 단면으로 확장 — 표본 100배+, 편향 제거.)
수익률 = (score_date+N 거래일 종가 score_date 종가) / score_date 종가
· 7d exit = [+6,+11]일 윈도 첫 거래일
· 30d exit = [+28,+38]일 윈도 첫 거래일
미래 미도달 종가는 NULL → 해당 horizon 학습에서 자동 제외(lookahead 차단)
segment: "all" | "regime:강세|중립|약세" | "sector:반도체..."
"""
where = "s.score_date >= $1"
params: list = [since]
if segment.startswith("regime:"):
params.append(segment.split(":", 1)[1])
where += (f" AND EXISTS (SELECT 1 FROM market_regime mr "
f"WHERE mr.dt=s.score_date AND mr.regime=${len(params)})")
elif segment.startswith("sector:"):
params.append(segment.split(":", 1)[1])
where += f" AND s.sector = ${len(params)}"
elif segment != "all":
return []
feat_cols = ", ".join(f"s.{f}" for f in LEARN_FEATURE_NAMES)
rows = await conn.fetch(f"""
WITH t AS (
SELECT s.stock_code, s.score_date, s.sector, s.signals, {feat_cols},
(SELECT o.close_price FROM stock_ohlcv o
WHERE o.stock_code=s.stock_code
AND o.dt<=s.score_date AND o.dt>=s.score_date-7
ORDER BY o.dt DESC LIMIT 1) AS entry_close,
(SELECT o.close_price FROM stock_ohlcv o
WHERE o.stock_code=s.stock_code
AND o.dt>=s.score_date+6 AND o.dt<=s.score_date+11
ORDER BY o.dt ASC LIMIT 1) AS close_7d,
(SELECT o.close_price FROM stock_ohlcv o
WHERE o.stock_code=s.stock_code
AND o.dt>=s.score_date+28 AND o.dt<=s.score_date+38
ORDER BY o.dt ASC LIMIT 1) AS close_30d,
(SELECT k.close_price FROM stock_ohlcv k
WHERE k.stock_code='KOSPI'
AND k.dt<=s.score_date AND k.dt>=s.score_date-7
ORDER BY k.dt DESC LIMIT 1) AS kospi_entry,
(SELECT k.close_price FROM stock_ohlcv k
WHERE k.stock_code='KOSPI'
AND k.dt>=s.score_date+28 AND k.dt<=s.score_date+38
ORDER BY k.dt ASC LIMIT 1) AS kospi_30d
FROM stock_scores s
WHERE {where}
)
SELECT *,
CASE WHEN entry_close>0 AND close_7d IS NOT NULL
THEN (close_7d-entry_close)/entry_close*100 END AS return_7d,
CASE WHEN entry_close>0 AND close_30d IS NOT NULL
THEN (close_30d-entry_close)/entry_close*100 END AS return_30d,
CASE WHEN entry_close>0 AND close_30d IS NOT NULL
AND kospi_entry>0 AND kospi_30d IS NOT NULL
THEN (close_30d-entry_close)/entry_close*100
- (kospi_30d-kospi_entry)/kospi_entry*100 END AS alpha_30d
FROM t
WHERE entry_close > 0
ORDER BY score_date ASC, stock_code ASC
""", *params)
return rows
def _eval_metrics(y_true, y_pred) -> dict:
"""IC(Spearman/Pearson), Hit ratio, Top-decile spread, Sharpe proxy, R²(OOS), MAE."""
import numpy as np
try:
from scipy import stats as _ss
from sklearn.metrics import r2_score, mean_absolute_error
except Exception:
return {}
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
if len(y_true) < 3:
return {}
out = {}
try:
out["ic_spearman"] = round(float(_ss.spearmanr(y_true, y_pred).correlation), 4)
except Exception:
out["ic_spearman"] = None
try:
out["ic_pearson"] = round(float(_ss.pearsonr(y_true, y_pred)[0]), 4)
except Exception:
out["ic_pearson"] = None
try:
out["hit_ratio"] = round(float(((y_pred > 0) == (y_true > 0)).mean()), 4)
except Exception:
out["hit_ratio"] = None
# Top-decile spread (예측 상위 10% 실제 평균 - 하위 10% 실제 평균)
n = len(y_pred)
if n >= 10:
order = np.argsort(y_pred)
d = max(1, n // 10)
out["top_decile_spread"] = round(float(y_true[order[-d:]].mean() - y_true[order[:d]].mean()), 4)
else:
out["top_decile_spread"] = None
# Sharpe proxy: 예측 부호로 long/short했을 때 평균/표준편차
try:
pnl = np.sign(y_pred) * y_true
sd = float(pnl.std())
out["sharpe_proxy"] = round(float(pnl.mean() / sd * (252 ** 0.5)), 4) if sd > 0 else None
except Exception:
out["sharpe_proxy"] = None
try:
out["r2_oos"] = round(float(r2_score(y_true, y_pred)), 4)
except Exception:
out["r2_oos"] = None
try:
out["mae"] = round(float(mean_absolute_error(y_true, y_pred)), 4)
except Exception:
out["mae"] = None
return out
def _walk_forward_folds(rows, n_folds: int, embargo_days: int = 0):
"""
시간순 정렬된 rows를 날짜(score_date) 경계로 (n_folds+1) 블록으로 나눈 expanding-window 폴드.
각 fold i: test=다음 날짜블록, train=그 이전 날짜들. 단,
① 같은 score_date가 train/test에 쪼개지지 않도록 '날짜' 단위로 분할
(인덱스 분할은 한 날짜의 종목들이 train/test로 갈라져 시장 단면 누수 발생)
② train의 마지막 embargo_days 구간은 purge — train 라벨의 미래 수익 윈도(+N일)가
test 피처 시점과 겹치는 패널 데이터 누수(López de Prado purge/embargo) 차단.
"""
n = len(rows)
if n < (n_folds + 1) * 3:
return []
dates = sorted({r["score_date"] for r in rows})
if len(dates) < n_folds + 1:
# 고유 날짜가 적으면 날짜 분할 불가 → 인덱스 분할로 폴백(임바고는 그대로 적용)
block = n // (n_folds + 1)
folds = []
for i in range(1, n_folds + 1):
te = rows[i * block:min(n, (i + 1) * block)]
if not te:
continue
emb_cut = te[0]["score_date"] - timedelta(days=embargo_days)
tr = [r for r in rows[:i * block] if r["score_date"] <= emb_cut]
if len(tr) < 5 or len(te) < 3:
continue
folds.append((tr, te))
return folds
dblock = len(dates) // (n_folds + 1)
folds = []
for i in range(1, n_folds + 1):
te_start_date = dates[i * dblock]
te_end_date = dates[min(len(dates), (i + 1) * dblock) - 1]
emb_cut = te_start_date - timedelta(days=embargo_days)
tr = [r for r in rows if r["score_date"] <= emb_cut]
te = [r for r in rows if te_start_date <= r["score_date"] <= te_end_date]
if len(tr) < 5 or len(te) < 3:
continue
folds.append((tr, te))
return folds
def _aggregate_fold_metrics(metric_list):
"""폴드별 metric dict의 평균. None은 제외."""
if not metric_list: return {}
keys = set()
for m in metric_list: keys.update(m.keys())
out = {}
for k in keys:
vals = [m[k] for m in metric_list if m.get(k) is not None]
out[k] = round(sum(vals) / len(vals), 4) if vals else None
return out
@app.post("/learn-pricing")
async def learn_pricing(days: int = Query(default=180, ge=14, le=730),
segment: str = Query(default="all"),
target: str = Query(default="return_30d"),
n_folds: int = Query(default=5, ge=2, le=10)):
"""
Walk-forward CV로 가격 모델 학습 + 평가지표 산출.
피처: 종합/앙상블/펀더멘털/기술/감성/뉴스/변동성 등 26개 (LEARN_FEATURE_NAMES)
모델: Linear / Random Forest / XGBoost (각각 별도 저장)
Segment: "all" | "regime:강세|중립|약세" | "sector:반도체|2차전지|..."
Target: "return_7d" | "return_30d" | "alpha_30d"
Lookahead bias 차단: score_date == rec_date, entry_price > 0, 시간순 expanding-window CV.
"""
if target not in ("return_7d", "return_30d", "alpha_30d"):
return {"err": f"target은 return_7d|return_30d|alpha_30d 중 하나여야 함 (받음: {target})",
"segment": segment, "target": target}
since = date.today() - timedelta(days=days)
async with pg_pool.acquire() as conn:
rows = await _fetch_training_rows(conn, since, segment)
# target NULL 제거 (해당 horizon 도달 안 한 표본 제외)
rows = [r for r in rows if r[target] is not None]
out = {"period_days": days, "sample": len(rows), "segment": segment, "target": target}
if len(rows) < (n_folds + 1) * 3:
out["msg"] = (f"표본 {len(rows)} 부족 (walk-forward {n_folds}fold ≥ {(n_folds+1)*3} 필요) "
f"— 추천·성과 누적 후 재학습")
return out
try:
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
except Exception as e:
return {**out, "err": f"sklearn import 실패: {e}"}
# 라벨 수익 윈도 길이만큼 train↔test 사이 임바고 (7d→+11일, 30d→+38일 윈도 → +1 여유)
embargo_days = {"return_7d": 12, "return_30d": 39, "alpha_30d": 39}.get(target, 39)
folds = _walk_forward_folds(rows, n_folds, embargo_days=embargo_days)
if not folds:
return {**out, "err": "fold 구성 실패 (표본 부족)"}
# ── 1. Linear (Ridge + 표준화) ─────────────────────────
# 피처 스케일이 -100~수천(점수 vs Amihud illiq vs log_mcap)으로 천차만별이라
# Ridge L2 패널티가 스케일에 휘둘려 계수가 왜곡됨 → 폴드마다 train에만 fit한
# StandardScaler를 파이프라인으로 결합. (scaler는 train 통계만 사용 → 누수 없음)
fold_metrics_lin = []
for tr, te in folds:
X_tr = np.array([_row_features(r) for r in tr])
y_tr = np.array([float(r[target]) for r in tr])
X_te = np.array([_row_features(r) for r in te])
y_te = np.array([float(r[target]) for r in te])
m = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(X_tr, y_tr)
fold_metrics_lin.append(_eval_metrics(y_te, m.predict(X_te)))
linear_metrics = _aggregate_fold_metrics(fold_metrics_lin)
# 전체 데이터 재학습 (배포용 모델) — 표준화 공간에서 학습 후 계수를 원본 피처 공간으로
# 역변환해 저장. /predict-price가 원본 피처로 intercept+Σcoef·x를 그대로 계산하므로
# 스케일러를 따로 들고 다닐 필요 없이 동일 예측이 보장됨.
X_all = np.array([_row_features(r) for r in rows])
y_all = np.array([float(r[target]) for r in rows])
final_pipe = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(X_all, y_all)
_scaler = final_pipe.named_steps["standardscaler"]
_ridge = final_pipe.named_steps["ridge"]
_scale = np.where(_scaler.scale_ == 0, 1.0, _scaler.scale_)
raw_coef = _ridge.coef_ / _scale
lin_intercept = float(_ridge.intercept_ - np.sum(_ridge.coef_ * _scaler.mean_ / _scale))
lin_coef = {fn: round(float(c), 6) for fn, c in zip(LEARN_FEATURE_NAMES, raw_coef)}
# ── 2. Random Forest ──────────────────────────────────
fold_metrics_rf = []
for tr, te in folds:
X_tr = np.array([_row_features(r) for r in tr])
y_tr = np.array([float(r[target]) for r in tr])
X_te = np.array([_row_features(r) for r in te])
y_te = np.array([float(r[target]) for r in te])
m = RandomForestRegressor(n_estimators=200, max_depth=6,
min_samples_leaf=3, random_state=42, n_jobs=2).fit(X_tr, y_tr)
fold_metrics_rf.append(_eval_metrics(y_te, m.predict(X_te)))
rf_metrics = _aggregate_fold_metrics(fold_metrics_rf)
final_rf = RandomForestRegressor(n_estimators=200, max_depth=6,
min_samples_leaf=3, random_state=42, n_jobs=2).fit(X_all, y_all)
rf_imp = dict(zip(LEARN_FEATURE_NAMES,
[round(float(v), 4) for v in final_rf.feature_importances_]))
rf_imp = dict(sorted(rf_imp.items(), key=lambda x: -x[1]))
# ── 3. XGBoost ────────────────────────────────────────
xgb_metrics = None; xgb_imp = None
try:
import xgboost as xgb
fold_metrics_xgb = []
for tr, te in folds:
X_tr = np.array([_row_features(r) for r in tr])
y_tr = np.array([float(r[target]) for r in tr])
X_te = np.array([_row_features(r) for r in te])
y_te = np.array([float(r[target]) for r in te])
m = xgb.XGBRegressor(n_estimators=200, max_depth=4, learning_rate=0.05,
subsample=0.85, colsample_bytree=0.85,
random_state=42, objective='reg:squarederror',
n_jobs=2).fit(X_tr, y_tr)
fold_metrics_xgb.append(_eval_metrics(y_te, m.predict(X_te)))
xgb_metrics = _aggregate_fold_metrics(fold_metrics_xgb)
final_xgb = xgb.XGBRegressor(n_estimators=200, max_depth=4, learning_rate=0.05,
subsample=0.85, colsample_bytree=0.85,
random_state=42, objective='reg:squarederror',
n_jobs=2).fit(X_all, y_all)
xgb_imp = dict(zip(LEARN_FEATURE_NAMES,
[round(float(v), 4) for v in final_xgb.feature_importances_]))
xgb_imp = dict(sorted(xgb_imp.items(), key=lambda x: -x[1]))
except Exception as ex:
xgb_metrics = {"err": str(ex)}
# ── 4. 저장 (pricing_model_v2 + model_metrics) ─────────
today_d = date.today()
async with pg_pool.acquire() as conn:
# Linear
await conn.execute("""
INSERT INTO pricing_model_v2
(model_date, segment, model_type, target,
feature_names, coef, intercept,
r2_oos, ic_spearman, hit_ratio, sample_size, period_days)
VALUES ($1,$2,'linear',$3,$4::jsonb,$5::jsonb,$6,$7,$8,$9,$10,$11)
ON CONFLICT (model_date, segment, model_type, target) DO UPDATE SET
feature_names=$4::jsonb, coef=$5::jsonb, intercept=$6,
r2_oos=$7, ic_spearman=$8, hit_ratio=$9,
sample_size=$10, period_days=$11
""", today_d, segment, target,
json.dumps(LEARN_FEATURE_NAMES), json.dumps(lin_coef),
lin_intercept,
linear_metrics.get("r2_oos"), linear_metrics.get("ic_spearman"),
linear_metrics.get("hit_ratio"), len(rows), days)
# RF
await conn.execute("""
INSERT INTO pricing_model_v2
(model_date, segment, model_type, target,
feature_names, feature_importance, model_blob,
r2_oos, ic_spearman, hit_ratio, sample_size, period_days)
VALUES ($1,$2,'rf',$3,$4::jsonb,$5::jsonb,$6,$7,$8,$9,$10,$11)
ON CONFLICT (model_date, segment, model_type, target) DO UPDATE SET
feature_names=$4::jsonb, feature_importance=$5::jsonb, model_blob=$6,
r2_oos=$7, ic_spearman=$8, hit_ratio=$9,
sample_size=$10, period_days=$11
""", today_d, segment, target,
json.dumps(LEARN_FEATURE_NAMES), json.dumps(rf_imp),
pickle.dumps(final_rf),
rf_metrics.get("r2_oos"), rf_metrics.get("ic_spearman"),
rf_metrics.get("hit_ratio"), len(rows), days)
# XGBoost (성공 시)
if xgb_imp is not None:
await conn.execute("""
INSERT INTO pricing_model_v2
(model_date, segment, model_type, target,
feature_names, feature_importance, model_blob,
r2_oos, ic_spearman, hit_ratio, sample_size, period_days)
VALUES ($1,$2,'xgb',$3,$4::jsonb,$5::jsonb,$6,$7,$8,$9,$10,$11)
ON CONFLICT (model_date, segment, model_type, target) DO UPDATE SET
feature_names=$4::jsonb, feature_importance=$5::jsonb, model_blob=$6,
r2_oos=$7, ic_spearman=$8, hit_ratio=$9,
sample_size=$10, period_days=$11
""", today_d, segment, target,
json.dumps(LEARN_FEATURE_NAMES), json.dumps(xgb_imp),
pickle.dumps(final_xgb),
xgb_metrics.get("r2_oos"), xgb_metrics.get("ic_spearman"),
xgb_metrics.get("hit_ratio"), len(rows), days)
# model_metrics — 폴드 평균값 저장
for mtype, m in (("linear", linear_metrics), ("rf", rf_metrics),
("xgb", xgb_metrics if xgb_imp is not None else None)):
if not m or "err" in m: continue
imp_dict = (lin_coef if mtype == "linear"
else rf_imp if mtype == "rf" else (xgb_imp or {}))
await conn.execute("""
INSERT INTO model_metrics
(model_date, model_type, segment, target,
period_days, sample_size, n_folds,
ic_spearman, ic_pearson, hit_ratio, top_decile_spread,
sharpe_proxy, r2_oos, mae, feature_importance)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb)
""", today_d, mtype, segment, target,
days, len(rows), len(folds),
m.get("ic_spearman"), m.get("ic_pearson"), m.get("hit_ratio"),
m.get("top_decile_spread"), m.get("sharpe_proxy"),
m.get("r2_oos"), m.get("mae"), json.dumps(imp_dict))
return {**out, "n_folds": len(folds), "n_features": len(LEARN_FEATURE_NAMES),
"linear": linear_metrics, "rf": rf_metrics, "xgb": xgb_metrics,
"rf_top_importance": dict(list(rf_imp.items())[:8]),
"applied": "다음 /predict-price 호출부터 적용 (segment 일치 모델 우선)"}
@app.get("/predict-price/{code}")
async def predict_price(code: str, model_type: str = Query(default="rf"),
target: str = Query(default="return_30d")):
"""
학습된 모델로 예상 수익률·가격 추정.
모델 선택 우선순위:
1) 종목 sector × 현재 regime 일치 segment 모델
2) 현재 regime 일치 segment 모델
3) sector 일치 segment 모델
4) segment='all' 모델 (default)
"""
async with pg_pool.acquire() as conn:
s = await conn.fetchrow(f"""
SELECT {", ".join(LEARN_FEATURE_NAMES)}, sector
FROM stock_scores WHERE stock_code=$1
ORDER BY score_date DESC LIMIT 1
""", code)
if not s:
return {"code": code, "msg": "stock_scores 데이터 없음"}
# 현재 regime
rg_row = await conn.fetchrow(
"SELECT regime FROM market_regime ORDER BY dt DESC LIMIT 1")
cur_regime = (rg_row["regime"] if rg_row else "중립") or "중립"
sector = (s["sector"] or "").strip()
candidates = []
if sector:
candidates.append(f"sector:{sector}")
if cur_regime:
candidates.append(f"regime:{cur_regime}")
candidates.append("all")
m = None; m_seg = None
for seg in candidates:
m = await conn.fetchrow("""
SELECT segment, model_type, feature_names, coef, intercept,
feature_importance, model_blob,
r2_oos, ic_spearman, hit_ratio, sample_size
FROM pricing_model_v2
WHERE segment=$1 AND model_type=$2 AND target=$3
ORDER BY model_date DESC LIMIT 1
""", seg, model_type, target)
if m:
m_seg = seg
break
# 현재가
cur_price = await get_current_price(code)
if not m:
return {"code": code, "msg": "학습 모델 없음 — 먼저 /learn-pricing 호출"}
# 예측: linear=coef 직접계산 / RF·XGB=저장된 모델(pickle) 로드 추론
pred_pct = None
used_method = model_type
if model_type == "linear":
coef = m["coef"]
if isinstance(coef, str):
try: coef = json.loads(coef)
except: coef = {}
intercept = float(m["intercept"] or 0)
pred_pct = intercept
for fn in LEARN_FEATURE_NAMES:
pred_pct += float(coef.get(fn, 0) or 0) * float(s[fn] or 0)
else:
# RF/XGB: 직렬화 저장된 모델로 직접 추론
blob = m["model_blob"]
if blob:
try:
import numpy as np
model = pickle.loads(blob)
fnames = m["feature_names"]
if isinstance(fnames, str):
try: fnames = json.loads(fnames)
except: fnames = []
fnames = fnames or LEARN_FEATURE_NAMES
vals = []
for fn in fnames:
try: vals.append(float(s[fn] if s[fn] is not None else 0))
except Exception: vals.append(0.0)
pred_pct = float(model.predict(np.array([vals], dtype=float))[0])
except Exception as e:
logger.warning("predict.blob_err", model_type=model_type, err=str(e))
if pred_pct is None:
# 저장 모델 없음/실패 → linear 계수로 fallback
used_method = "linear(fallback)"
lm = await pg_pool.fetchrow("""
SELECT coef, intercept FROM pricing_model_v2
WHERE segment=$1 AND model_type='linear' AND target=$2
ORDER BY model_date DESC LIMIT 1
""", m_seg, target)
if lm:
coef = lm["coef"]
if isinstance(coef, str):
try: coef = json.loads(coef)
except: coef = {}
pred_pct = float(lm["intercept"] or 0)
for fn in LEARN_FEATURE_NAMES:
pred_pct += float(coef.get(fn, 0) or 0) * float(s[fn] or 0)
if pred_pct is None:
return {"code": code, "msg": f"{model_type} 모델 학습 불가 — linear fallback도 없음"}
horizon_days = {"return_7d": 7, "return_30d": 30, "alpha_30d": 30}.get(target, 30)
pred_price = int(cur_price * (1 + pred_pct / 100)) if cur_price else None
return {
"code": code,
"current_price": cur_price,
"current_regime": cur_regime,
"sector": sector,
"model_used": {"segment": m_seg, "type": used_method, "target": target,
"r2_oos": m["r2_oos"], "ic_spearman": m["ic_spearman"],
"hit_ratio": m["hit_ratio"], "n": m["sample_size"]},
"predicted_return_pct": round(pred_pct, 2) if pred_pct is not None else None,
"horizon_days": horizon_days,
"predicted_price": pred_price,
"disclaimer": "Walk-forward CV 학습. IC > 0.05 면 유효 신호. RF/XGB는 저장된 모델로 직접 추론.",
}
@app.get("/model-metrics")
async def model_metrics_view(days: int = Query(default=30), segment: str = Query(default="")):
"""최근 학습된 모델들의 walk-forward CV 평가지표 조회"""
since = date.today() - timedelta(days=days)
where = "model_date >= $1"
params: list = [since]
if segment:
params.append(segment)
where += f" AND segment = ${len(params)}"
async with pg_pool.acquire() as conn:
rows = await conn.fetch(f"""
SELECT model_date, model_type, segment, target,
sample_size, n_folds,
ic_spearman, ic_pearson, hit_ratio,
top_decile_spread, sharpe_proxy, r2_oos, mae
FROM model_metrics WHERE {where}
ORDER BY model_date DESC, segment ASC, model_type ASC
""", *params)
return {"count": len(rows), "rows": [dict(r) for r in rows]}
@app.get("/portfolio/recommended")
async def portfolio_recommended(amount: int = Query(default=0, ge=0),
max_stocks: int = Query(default=12, ge=3, le=30),
sector_cap: float = Query(default=30.0, ge=10, le=100)):
"""오늘 추천 종목으로 분산 포트폴리오 구성.
비중 = position_size_pct(변동성·점수 가중) 정규화 + 섹터 상한(기본 30%).
amount(원)를 주면 종목별 배분금액·매수가능주수까지 계산."""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation,
COALESCE(s.position_size_pct, 0) AS psize,
COALESCE(NULLIF(s.sector, ''), '기타') AS sector,
s.top_reasons
FROM stock_scores s
JOIN dart_corps d ON d.stock_code = s.stock_code AND d.is_active = true
WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores)
AND s.recommendation IN ('강력매수', '매수관심')
ORDER BY s.total_score DESC
LIMIT $1
""", max_stocks)
if not rows:
return {"as_of": str(date.today()), "n_stocks": 0, "holdings": [],
"sector_breakdown": {},
"msg": "현재 추천(강력매수·매수관심) 종목이 없습니다."}
def _reason(tr):
if isinstance(tr, list):
return " · ".join(str(x) for x in tr[:2])[:80]
return str(tr)[:80] if tr else ""
items = []
for r in rows:
psize = float(r["psize"] or 0)
raw = psize if psize > 0 else max(1.0, float(r["total_score"]) / 12.0)
items.append({
"stock_code": r["stock_code"],
"stock_name": r["stock_name"] or r["stock_code"],
"total_score": round(float(r["total_score"]), 1),
"recommendation": r["recommendation"],
"sector": r["sector"], "raw": raw, "weight": 0.0,
"reason": _reason(r["top_reasons"]),
})
def _normalize(its):
tot = sum(i["raw"] for i in its) or 1.0
for i in its:
i["weight"] = i["raw"] / tot * 100.0
_normalize(items)
# 섹터 상한 반복 적용 — 한 섹터가 sector_cap 초과 시 축소 후 재정규화
for _ in range(4):
ssum: dict = {}
for i in items:
ssum[i["sector"]] = ssum.get(i["sector"], 0.0) + i["weight"]
over = {s: v for s, v in ssum.items() if v > sector_cap + 0.01}
if not over:
break
for i in items:
i["raw"] = (i["weight"] * sector_cap / ssum[i["sector"]]
if i["sector"] in over else i["weight"])
_normalize(items)
holdings = []
for i in items:
price = await get_current_price(i["stock_code"]) if amount > 0 else 0
alloc = int(amount * i["weight"] / 100.0) if amount > 0 else 0
shares = (alloc // price) if (amount > 0 and price > 0) else 0
holdings.append({
"stock_code": i["stock_code"], "stock_name": i["stock_name"],
"sector": i["sector"], "total_score": i["total_score"],
"recommendation": i["recommendation"],
"weight_pct": round(i["weight"], 2),
"price": price, "alloc_amount": alloc, "shares": shares,
"invest_amount": shares * price, "reason": i["reason"],
})
holdings.sort(key=lambda h: -h["weight_pct"])
sector_breakdown: dict = {}
for h in holdings:
sector_breakdown[h["sector"]] = round(
sector_breakdown.get(h["sector"], 0.0) + h["weight_pct"], 2)
sector_breakdown = dict(sorted(sector_breakdown.items(), key=lambda x: -x[1]))
invested = sum(h["invest_amount"] for h in holdings)
return {
"as_of": str(date.today()),
"n_stocks": len(holdings),
"input_amount": amount,
"invested_amount": invested,
"cash_remaining": max(0, amount - invested) if amount > 0 else 0,
"sector_breakdown": sector_breakdown,
"holdings": holdings,
}
@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}")
# 단기 가격 추세 (5d/20d 수익률) — 떨어지는 칼날 진단
try:
ret_5d, ret_20d = await calc_short_returns(conn, code)
if ret_5d != 0.0 or ret_20d != 0.0:
warn = ""
if ret_5d <= -8.0 or ret_20d <= -15.0:
warn = " ⚠️ 강한 단기약세 (등급 강등 트리거)"
elif ret_5d <= -5.0 or ret_20d <= -10.0:
warn = " ⚠️ 단기약세 (한 단계 강등 트리거)"
ctx.append(f"· 단기 추세: 5일 {ret_5d:+.1f}% / 20일 {ret_20d:+.1f}%{warn}")
except Exception:
pass
# 공매도 잔고 변화 (5일 추세) — 시장 매도 압력 인디케이터
try:
shorts = await conn.fetch("""
SELECT dt, short_balance_qty, trade_weight FROM stock_short_sale
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 5
""", code)
if len(shorts) >= 2:
latest_bal = shorts[0]["short_balance_qty"] or 0
oldest_bal = shorts[-1]["short_balance_qty"] or 0
latest_w = shorts[0]["trade_weight"] or 0
if oldest_bal > 0:
chg = (latest_bal - oldest_bal) / oldest_bal * 100
trend = "감소" if chg < -3 else ("증가" if chg > 3 else "유지")
ctx.append(f"· 공매도: 거래비중 {latest_w:.1f}% / 5일 잔고변화 {chg:+.1f}% ({trend})")
except Exception:
pass
# 외국인 순매수 추세 (5일 합)
try:
flows = await conn.fetch("""
SELECT foreign_net, institution_net FROM stock_ohlcv
WHERE stock_code=$1 AND dt >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY dt DESC LIMIT 5
""", code)
if flows:
f_sum = sum(int(r["foreign_net"] or 0) for r in flows)
i_sum = sum(int(r["institution_net"] or 0) for r in flows)
ctx.append(f"· 수급(5일 합): 외국인 {f_sum/1e8:+.0f}억 / 기관 {i_sum/1e8:+.0f}")
except Exception:
pass
# 최근 14일 DART 공시 — 정정·계약 등 시장 민감 키워드 강조
try:
discs = await conn.fetch("""
SELECT rcept_dt, report_nm FROM dart_disclosures
WHERE stock_code=$1
AND rcept_dt::date >= CURRENT_DATE - INTERVAL '14 days'
ORDER BY rcept_dt DESC LIMIT 5
""", code)
if discs:
ctx.append("· 최근 공시(14일):")
for d in discs:
tag = ""
nm = d["report_nm"] or ""
if "정정" in nm or "기재정정" in nm:
tag = " ⚠️정정"
elif "단일판매" in nm or "공급계약" in nm:
tag = " 💰계약"
ctx.append(f" - {d['rcept_dt']} {nm.strip()}{tag}")
except Exception:
pass
return "\n".join(ctx), meta
_DEEP_SYSTEM = (
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
"제공된 정량 데이터(퀀트 종합점수·학술공식 신호·재무추세·기술적·뉴스흐름)를 "
"종합해 매수/매도를 판단합니다.\n"
"판단 우선순위: 기업 본질가치(ROE·영업이익률·FCF·부채안정성·이익품질) > "
"밸류에이션(PER·PBR·DCF안전마진) > 뉴스 catalyst·모멘텀 > 단기 수급.\n"
"주어진 데이터에 근거해서만 판단하고 데이터에 없는 사실을 지어내지 마세요.\n"
"퀀트 시스템판정과 다른 결론을 내릴 경우 그 이유를 thesis에 명확히 쓰세요.\n"
"민감 신호 가드: "
"(1) 단기 추세 5일 ≤ -5% 또는 20일 ≤ -10%'떨어지는 칼날' 위험 — 펀더 강해도 '매수' 보류 권고. "
"(2) 최근 14일 ⚠️정정 공시가 있고 시장 신뢰 영향이 의심되면 risks에 반드시 기재. "
"(3) 공매도 잔고 증가 + 거래비중 10% 초과는 매도 압력 강함 → 매수 등급 하향. "
"(4) PER > 80은 'valuation_view: 고평가'로 명시. "
"(5) 외국인 5일 누적 매도 우세면 risks에 수급 악화 명시.\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"]
# 1차: 정상 JSON 추출 — 첫 객체만 (모델이 JSON 뒤에 코멘트 붙이는 경우 방어)
parsed = _extract_json(c)
if parsed:
return parsed
# 2차: 첫 { ... } 균형 잡힌 부분만 추출 후 재시도
s = c.find("{")
if s == -1:
return {}
depth = 0
for i in range(s, len(c)):
if c[i] == "{": depth += 1
elif c[i] == "}":
depth -= 1
if depth == 0:
sub = c[s:i+1].translate({k: " " for k in range(0x20) if k not in (0x09, 0x0a, 0x0d)})
try:
return json.loads(sub)
except Exception:
break
return {}
except Exception as e:
logger.warning("deep.exaone_err", error=str(e))
return {}
def _extract_json(text: str) -> dict:
"""LLM 응답 텍스트에서 JSON 객체 추출 (Markdown·접두사 방어)."""
if not text:
return {}
try:
c = text.replace("```json", "").replace("```", "").strip()
if not c.startswith("{"):
s, e = c.find("{"), c.rfind("}")
if s != -1 and e > s:
c = c[s:e + 1]
c = c.translate({i: " " for i in range(0x20) if i not in (0x09, 0x0a, 0x0d)})
return json.loads(c)
except Exception:
return {}
class _GeminiDeepResponse(BaseModel):
"""Gemini response_schema (Structured Output) — 프롬프트에서 스키마 텍스트 제거 → 입력 토큰 절감."""
recommendation: Literal["강력매수", "매수", "중립", "매도", "강력매도"]
conviction: int = Field(ge=1, le=5)
thesis: str
key_points: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
catalyst_watch: list[str] = Field(default_factory=list)
valuation_view: Literal["저평가", "적정", "고평가"]
time_horizon: Literal["단기", "중기", "장기"]
target_price: int
stop_loss: int
async def _gemini_json(system: str, user: str) -> tuple[dict, int]:
"""Gemini 2.5 Pro 호출 — 토큰 절감 옵션 적용:
A. system_instruction 분리 → implicit prefix cache hit (캐시 시 입력 75%↓)
B. response_schema (Pydantic) → 프롬프트 스키마 텍스트 제거 + JSON 출력 강제
C. max_output_tokens=2000 → thinking+응답 합산 상한 (폭주 방지)
returns: (parsed_json, estimated_cost_krw)
"""
if not GEMINI_API_KEY:
return {}, 0
# === 글로벌 일일 1회 한도 ===
try:
async with pg_pool.acquire() as conn:
cnt = await conn.fetchval(
"SELECT COUNT(*) FROM gemini_call_log WHERE called_at::date = CURRENT_DATE")
if cnt and int(cnt) >= 1:
logger.warning("gemini.daily_limit", source="score_engine", today_count=int(cnt))
return {}, 0
except Exception as e:
logger.warning("gemini.limit_check_err", error=str(e))
try:
from google import genai
from google.genai import types
client = genai.Client(api_key=GEMINI_API_KEY)
config = types.GenerateContentConfig(
system_instruction=system,
temperature=0.2,
response_mime_type="application/json",
response_schema=_GeminiDeepResponse,
max_output_tokens=4000, # Pro thinking + 응답 합산 — 비용 폭주로 8000→4000 축소
)
resp = await asyncio.to_thread(
client.models.generate_content,
model=GEMINI_MODEL,
contents=user,
config=config,
)
text = getattr(resp, "text", "") or ""
parsed = _extract_json(text)
# 비용 추정 (Gemini 2.5 Pro: $1.25/M input + $10/M output, 환율 1400원/$ 가정)
# ⚠️ thinking 토큰은 candidates_token_count에 안 들어감 → total - prompt 로 출력 전체 산출
usage = getattr(resp, "usage_metadata", None)
cost_krw = 0
in_tok = out_tok = cached_tok = 0
if usage:
in_tok = getattr(usage, "prompt_token_count", 0) or 0
total_tok = getattr(usage, "total_token_count", 0) or 0
cached_tok = getattr(usage, "cached_content_token_count", 0) or 0
# 출력 전체(candidates + thinking) = total - prompt
out_tok = max(total_tok - in_tok, getattr(usage, "candidates_token_count", 0) or 0)
paid_in = max(in_tok - cached_tok, 0)
cost_usd = (paid_in / 1_000_000 * 1.25) + (cached_tok / 1_000_000 * 0.31) + (out_tok / 1_000_000 * 10.0)
cost_krw = int(cost_usd * 1400)
# 일일 한도 카운터 기록 (호출 성사된 경우만)
try:
async with pg_pool.acquire() as conn:
await conn.execute(
"INSERT INTO gemini_call_log (source, cost_krw) VALUES ($1, $2)",
"score_engine", cost_krw)
except Exception:
pass
if not parsed:
finish = ""
try:
cands = getattr(resp, "candidates", None) or []
if cands:
finish = str(getattr(cands[0], "finish_reason", "") or "")
except Exception:
pass
logger.warning("deep.gemini_empty",
text_len=len(text), in_tok=in_tok, out_tok=out_tok,
cached=cached_tok, finish=finish, text_head=text[:200])
else:
logger.info("deep.gemini_ok",
in_tok=in_tok, out_tok=out_tok, cached=cached_tok,
cost_krw=cost_krw)
return parsed, cost_krw
except Exception as e:
logger.warning("deep.gemini_err", error=str(e))
return {}, 0
def _norm_int(v) -> int:
try:
return int(float(v))
except Exception:
return 0
def _normalize_llm(a: dict, tg: dict) -> dict:
"""LLM 원시 응답 → 정규화된 분석 dict (rec/conv/tp/sl/thesis/key_points/...)."""
def _clean(lst):
return [str(x).lstrip("-*•· ").strip()
for x in (lst or []) if str(x).strip()][:6]
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))
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)
return {
"recommendation": rec, "conviction": conv,
"thesis": str(a.get("thesis", "") or "")[:1000],
"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": bool(a),
}
async def run_deep_analysis(conn, client, code: str, save: bool = True,
model: str = "hybrid") -> dict:
"""LLM 종목 심층분석.
model:
'exaone' — EXAONE만 (기본 무료)
'gemini' — Gemini만 (키 필요, Grounding OFF)
'hybrid' — 둘 다 호출하여 비교 (키 있을 때 자동, 없으면 exaone 폴백)
"""
ctx, meta = await _build_rag_context(conn, code)
if "재무: DART 연간 재무데이터 없음" in ctx and meta["quant_score"] == 0.0:
return {"code": code, "error": "데이터 부족 (재무·퀀트 모두 없음)"}
base_user = (f"[분석 대상]\n{ctx}\n\n"
f"위 데이터를 종합해 버핏 가치투자 관점에서 매수/매도를 판단하세요.")
gem_user = base_user # Gemini는 response_schema가 강제 → 스키마 텍스트 불필요
user = f"{base_user}\nJSON 스키마:\n{_DEEP_SCHEMA}" # EXAONE용 (스키마 텍스트 포함)
tg = meta.get("targets") or {}
has_gemini = bool(GEMINI_API_KEY)
use_exaone = model in ("exaone", "hybrid") or (model == "gemini" and not has_gemini)
use_gemini = (model in ("gemini", "hybrid")) and has_gemini
exaone_norm = None
gemini_norm = None
cost_krw = 0
# Hybrid 모드: Gemini 먼저 호출 → 그 답을 EXAONE 컨텍스트에 주입 (reflection)
# → EXAONE이 Gemini 분석을 참고해 자기 판단 재평가 → 학습 효과 흉내
if use_gemini:
try:
raw, c_krw = await _gemini_json(_DEEP_SYSTEM, gem_user)
gemini_norm = _normalize_llm(raw, tg)
cost_krw += c_krw
except Exception as e:
logger.warning("deep.llm_err", llm="gemini", error=str(e))
if use_exaone:
ex_user = user
if gemini_norm and gemini_norm["parsed_ok"]:
ex_user += (
f"\n\n[참고: 외부 분석가(Gemini)는 다음과 같이 판단했습니다]\n"
f"- 판단: {gemini_norm['recommendation']} (확신도 {gemini_norm['conviction']}/5)\n"
f"- 밸류: {gemini_norm['valuation_view']} / 기간: {gemini_norm['time_horizon']}\n"
f"- 논거: {gemini_norm['thesis'][:400]}\n"
f"- 목표가 {gemini_norm['target_price']:,}원 / 손절 {gemini_norm['stop_loss']:,}\n"
f"위 외부 분석을 참고하되, 데이터로 입증된 결론만 채택하세요. "
f"동의하면 같은 방향, 다르면 명확한 근거를 thesis에 쓰세요."
)
try:
res = await _exaone_json(client, _DEEP_SYSTEM, ex_user)
exaone_norm = _normalize_llm(res, tg)
except Exception as e:
logger.warning("deep.llm_err", llm="exaone", error=str(e))
# 메인 결과는 hybrid이면 Gemini 우선(외부 검색 반영), 아니면 호출한 쪽
main = gemini_norm if (model in ("gemini", "hybrid") and gemini_norm and gemini_norm["parsed_ok"]) else exaone_norm
if not main:
return {"code": code, "error": "LLM 응답 없음"}
agreement = None
if exaone_norm and gemini_norm and exaone_norm["parsed_ok"] and gemini_norm["parsed_ok"]:
agreement = exaone_norm["recommendation"] == gemini_norm["recommendation"]
report = {**main}
if exaone_norm and exaone_norm is not main:
report["exaone"] = {k: exaone_norm[k] for k in
("recommendation", "conviction", "thesis", "target_price", "stop_loss")}
if gemini_norm and gemini_norm is not main:
report["gemini"] = {k: gemini_norm[k] for k in
("recommendation", "conviction", "thesis", "target_price", "stop_loss")}
if save and main["parsed_ok"]:
# EXAONE 결과는 메인 컬럼 / Gemini 결과는 gemini_* 컬럼 분리 저장
# training_label = Gemini 답 (학습용 pseudo-ground-truth, 30d 후 사후검증 대상)
exa = exaone_norm or main
gem = gemini_norm or {"recommendation": None, "conviction": 0, "thesis": "",
"target_price": 0, "stop_loss": 0}
training_label = gem["recommendation"] if gemini_norm and gemini_norm["parsed_ok"] else main["recommendation"]
# entry_price: 현재가 (사후 30d 수익률 계산 기준)
cur_price = await conn.fetchval(
"SELECT price FROM stock_technical WHERE stock_code=$1", code) or 0
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,
gemini_recommendation, gemini_conviction, gemini_thesis,
gemini_target_price, gemini_stop_loss, gemini_report,
agreement, llm_cost_krw, training_label, entry_price)
VALUES ($1,$2,CURRENT_DATE,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
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,
gemini_recommendation=EXCLUDED.gemini_recommendation,
gemini_conviction=EXCLUDED.gemini_conviction,
gemini_thesis=EXCLUDED.gemini_thesis,
gemini_target_price=EXCLUDED.gemini_target_price,
gemini_stop_loss=EXCLUDED.gemini_stop_loss,
gemini_report=EXCLUDED.gemini_report,
agreement=EXCLUDED.agreement, llm_cost_krw=EXCLUDED.llm_cost_krw,
training_label=EXCLUDED.training_label,
entry_price=EXCLUDED.entry_price,
created_at=NOW()
""", code, meta["name"],
exa["recommendation"], exa["conviction"], exa["target_price"],
exa["stop_loss"], exa["thesis"][:1000],
json.dumps(report, ensure_ascii=False), ctx, meta["quant_score"],
gem["recommendation"], gem["conviction"], (gem["thesis"] or "")[:1000],
gem["target_price"], gem["stop_loss"],
json.dumps(gemini_norm or {}, ensure_ascii=False),
agreement, cost_krw, training_label, int(cur_price))
return {
"code": code, "name": meta["name"],
"quant_score": meta["quant_score"], "quant_rec": meta["quant_rec"],
"agreement": agreement, "llm_cost_krw": cost_krw,
**report,
}
def _fmt_deep(r: dict) -> str:
if r.get("error"):
return f"⚠️ {r['code']}: {r['error']}"
icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "",
"매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "")
# 하이브리드 검증 표시 (EXAONE vs Gemini 일치 여부)
agr = r.get("agreement")
agr_tag = ""
if agr is True:
agr_tag = " ✅두AI일치"
elif agr is False:
agr_tag = " ⚠️AI의견갈림"
cost = r.get("llm_cost_krw", 0)
cost_tag = f" · 비용 {cost}" if cost else ""
lines = [
f"{icon} <b>{r['name']}({r['code']})</b> — AI심층분석{agr_tag}",
f"판단: <b>{r['recommendation']}</b> (확신도 {r['conviction']}/5) "
f"· 퀀트 {r['quant_score']:.0f}점[{r['quant_rec']}]{cost_tag}",
f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}",
"",
f"<b>📝 투자논거</b>\n{r['thesis']}",
]
# 두 LLM 의견 갈림 시 다른 쪽 의견 동봉
if agr is False:
other = r.get("exaone") or r.get("gemini")
if other:
label = "EXAONE" if "exaone" in r else "Gemini"
lines.append(f"\n<b>🔄 {label} 의견 (참고)</b>")
lines.append(f" {other.get('recommendation','-')} {other.get('conviction',0)}/5 — {other.get('thesis','')[:200]}")
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),
model: str = Query(default="hybrid",
regex="^(exaone|gemini|hybrid)$")):
"""RAG + LLM 종목 심층분석. model=hybrid(기본): EXAONE+Gemini 동시 호출하여 비교."""
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,
agreement, llm_cost_krw
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,
"agreement": cached["agreement"],
"llm_cost_krw": cached["llm_cost_krw"] or 0,
**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, model=model)
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.get("/hybrid-stats")
async def hybrid_stats(days: int = Query(default=30, ge=1, le=365)):
"""EXAONE vs Gemini 일치율·비용 통계."""
async with pg_pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE agreement IS NOT NULL) AS dual,
COUNT(*) FILTER (WHERE agreement = true) AS agreed,
COUNT(*) FILTER (WHERE agreement = false) AS disagreed,
COALESCE(SUM(llm_cost_krw), 0) AS cost_krw
FROM deep_analysis
WHERE analysis_date >= CURRENT_DATE - $1::int
""", days)
return {
"period_days": days,
"total_analyses": row["total"],
"dual_llm_runs": row["dual"],
"agreed": row["agreed"], "disagreed": row["disagreed"],
"agreement_rate": round(row["agreed"] / row["dual"] * 100, 1) if row["dual"] else None,
"total_cost_krw": int(row["cost_krw"]),
}
@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))
async def morning_brief_job():
"""평일 08:00 아침 자동 브리핑.
- 보유 종목(user_portfolio) 손익 + 전일 17:00 deep_analysis 판단 표시
- 그 외 톱 추천도 deep_analysis 저장본 재사용 (LLM 재호출 없음 → 비용 0원)
- 분석이 없거나 7일 이상 stale이면 안내 후 /deep 수동 호출 권유
"""
try:
async with pg_pool.acquire() as conn:
portfolio = await conn.fetch("""
SELECT stock_code, qty, buy_price FROM user_portfolio
WHERE active=true ORDER BY created_at DESC
""")
top_codes = [r["stock_code"] for r in await conn.fetch("""
SELECT stock_code FROM stock_scores
WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
AND recommendation IN ('강력매수','매수관심')
ORDER BY total_score DESC LIMIT 10
""")]
own_codes = [p["stock_code"] for p in portfolio]
own_meta = {p["stock_code"]: p for p in portfolio}
seen = set()
codes = [c for c in (own_codes + top_codes) if not (c in seen or seen.add(c))]
if not codes:
return
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code)
stock_code, stock_name, recommendation, conviction,
thesis, agreement, target_price, quant_score, analysis_date
FROM deep_analysis
WHERE stock_code = ANY($1)
ORDER BY stock_code, analysis_date DESC
""", codes)
prices = {r["stock_code"]: r["price"] or 0 for r in await conn.fetch(
"SELECT stock_code, price FROM stock_technical WHERE stock_code = ANY($1)",
codes)}
month_cost = await conn.fetchval("""
SELECT COALESCE(SUM(llm_cost_krw), 0) FROM deep_analysis
WHERE analysis_date >= date_trunc('month', CURRENT_DATE)
""")
analyzed = {r["stock_code"]: r for r in rows}
today = date.today()
lines = [f"🌅 <b>아침 브리핑 ({today.isoformat()})</b>"]
if own_codes:
lines.append("\n<b>👜 보유 종목 진단</b>")
for code in own_codes:
p = own_meta[code]
price = prices.get(code, 0)
buy = p["buy_price"]
pnl_pct = (price - buy) / buy * 100 if buy and price else 0
pnl_won = (price - buy) * p["qty"] if buy and price else 0
r = analyzed.get(code)
if r:
age = (today - r["analysis_date"]).days
stale = " (분석 " + (f"{age}일 전" if age else "어제") + ")" if age else ""
agr = r["agreement"]
tag = "" if agr else ("⚠️" if agr is False else "")
lines.append(
f" {tag} <b>{r['stock_name']}</b>({code}) "
f"{p['qty']}주 @{buy:,}원 → 현재 {price:,}"
f"<b>{pnl_pct:+.1f}%</b> ({pnl_won:+,}원)\n"
f" 판단: <b>{r['recommendation']}</b> {r['conviction']}/5{stale}{(r['thesis'] or '')[:120]}"
)
else:
lines.append(
f" • ({code}) {p['qty']}주 @{buy:,}원 → 현재 {price:,}"
f"<b>{pnl_pct:+.1f}%</b> ({pnl_won:+,}원) — 분석 없음, /deep {code}"
)
new_codes = [c for c in codes if c not in own_meta and c in analyzed]
if new_codes:
lines.append("\n<b>📈 오늘의 추천 (전날 분석)</b>")
for code in new_codes:
r = analyzed[code]
age = (today - r["analysis_date"]).days
stale = f" · {age}일 전" if age else ""
agr = r["agreement"]
tag = "" if agr else ("⚠️" if agr is False else "")
lines.append(
f" {tag} <b>{r['stock_name']}</b>({code}) — "
f"{r['recommendation']} {r['conviction']}/5 · "
f"퀀트 {(r['quant_score'] or 0):.0f} · "
f"목표 {(r['target_price'] or 0):,}{stale}"
)
lines.append(f"\n💰 이번 달 LLM 비용: {int(month_cost)}원 (오늘 0원 — 저장본 재사용)")
lines.append(f"\n자세한 분석은 /deep &lt;코드&gt; 명령")
await send_telegram("\n".join(lines))
logger.info("morning_brief.done", stocks=len(analyzed), cost_krw=0)
except Exception as e:
logger.error("morning_brief.err", error=str(e))
@app.post("/portfolio/register")
async def portfolio_register(code: str = Query(...),
buy_price: int = Query(..., gt=0),
qty: int = Query(default=1, gt=0),
memo: str = Query(default="")):
async with pg_pool.acquire() as conn:
name = await conn.fetchval(
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) or ""
row = await conn.fetchrow("""
INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
VALUES ($1, $2, $3, $4, $5) RETURNING id
""", code, name, buy_price, qty, memo)
return {"id": row["id"], "code": code, "name": name, "buy_price": buy_price, "qty": qty}
@app.get("/portfolio")
async def portfolio_list(active_only: bool = Query(default=True)):
where = "WHERE p.active=true" if active_only else ""
async with pg_pool.acquire() as conn:
rows = await conn.fetch(f"""
SELECT p.*, d.corp_name,
(SELECT price FROM stock_technical WHERE stock_code=p.stock_code) AS current_price
FROM user_portfolio p
LEFT JOIN dart_corps d ON d.stock_code=p.stock_code
{where} ORDER BY p.created_at DESC
""")
out = []
for r in rows:
d = dict(r)
cur = int(d.get("current_price") or 0)
if cur and d["buy_price"]:
d["pnl_pct"] = round((cur - d["buy_price"]) / d["buy_price"] * 100, 2)
d["pnl_won"] = (cur - d["buy_price"]) * d["qty"]
out.append(d)
return out
@app.delete("/portfolio/{portfolio_id}")
async def portfolio_delete(portfolio_id: int):
async with pg_pool.acquire() as conn:
await conn.execute("UPDATE user_portfolio SET active=false WHERE id=$1", portfolio_id)
return {"status": "deactivated", "id": portfolio_id}
@app.post("/morning-brief/run")
async def morning_brief_manual():
"""수동 트리거 — 평일 8시 기다리지 않고 즉시 실행."""
asyncio.create_task(morning_brief_job())
return {"status": "started", "note": "텔레그램 확인. 보유종목 + 톱 10 분석 ~2~3분 소요"}
async def verify_predictions_job():
"""매일 03:00 — 30일 전 분석들 사후 검증 (실제 30d 수익률 매칭 → 누가 맞았나)."""
try:
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, stock_code, analysis_date, entry_price,
recommendation, gemini_recommendation
FROM deep_analysis
WHERE verified_at IS NULL
AND analysis_date <= CURRENT_DATE - INTERVAL '30 days'
AND entry_price > 0
LIMIT 500
""")
ok = 0
for r in rows:
price_30d = await conn.fetchval("""
SELECT close_price FROM stock_ohlcv
WHERE stock_code=$1 AND dt >= $2::date + INTERVAL '30 days'
ORDER BY dt ASC LIMIT 1
""", r["stock_code"], r["analysis_date"])
if not price_30d:
continue
ret = (price_30d - r["entry_price"]) / r["entry_price"] * 100
# 판단 정답 여부: 매수면 수익률>0, 매도면 <0, 중립이면 |수익률|<5
def _correct(rec):
if not rec: return None
if rec in ("강력매수","매수"): return ret > 0
if rec in ("강력매도","매도"): return ret < 0
if rec == "중립": return abs(ret) < 5
return None
await conn.execute("""
UPDATE deep_analysis SET realized_price_30d=$1,
realized_return_30d=$2, exaone_correct=$3, gemini_correct=$4,
verified_at=NOW()
WHERE id=$5
""", price_30d, ret,
_correct(r["recommendation"]), _correct(r["gemini_recommendation"]),
r["id"])
ok += 1
logger.info("verify_predictions.done", verified=ok)
except Exception as e:
logger.error("verify_predictions.err", error=str(e))
@app.get("/labels/stats")
async def labels_stats(days: int = Query(default=90, ge=1, le=730)):
"""LLM 라벨 사후검증 통계 (Gemini vs EXAONE 정확도)."""
async with pg_pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE verified_at IS NOT NULL) AS verified,
COUNT(*) FILTER (WHERE gemini_correct = true) AS gem_ok,
COUNT(*) FILTER (WHERE gemini_correct = false) AS gem_no,
COUNT(*) FILTER (WHERE exaone_correct = true) AS exa_ok,
COUNT(*) FILTER (WHERE exaone_correct = false) AS exa_no,
AVG(realized_return_30d) FILTER (WHERE gemini_recommendation IN ('강력매수','매수')) AS avg_buy_ret,
AVG(realized_return_30d) FILTER (WHERE gemini_recommendation IN ('강력매도','매도')) AS avg_sell_ret
FROM deep_analysis
WHERE analysis_date >= CURRENT_DATE - $1::int
""", days)
g_tot = (row["gem_ok"] or 0) + (row["gem_no"] or 0)
e_tot = (row["exa_ok"] or 0) + (row["exa_no"] or 0)
return {
"period_days": days,
"verified": row["verified"],
"gemini_accuracy": round(row["gem_ok"] / g_tot * 100, 1) if g_tot else None,
"exaone_accuracy": round(row["exa_ok"] / e_tot * 100, 1) if e_tot else None,
"gemini_buy_avg_30d_return": round(row["avg_buy_ret"], 2) if row["avg_buy_ret"] else None,
"gemini_sell_avg_30d_return": round(row["avg_sell_ret"], 2) if row["avg_sell_ret"] else None,
}
# ── Phase 4: 자동매매 신호 + 텔레그램 confirm 인프라 ─────────
async def ensure_trading_tables():
"""trading_orders + trading_daily_pnl 테이블 보장 (init_db에서 호출)."""
async with pg_pool.acquire() as conn:
# 기존 30분 default 컬럼을 6시간으로 ALTER
await conn.execute(
"ALTER TABLE IF EXISTS trading_orders "
"ALTER COLUMN expires_at SET DEFAULT (NOW() + INTERVAL '6 hour')")
await conn.execute("""
CREATE TABLE IF NOT EXISTS trading_orders (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10) NOT NULL,
stock_name VARCHAR(100) DEFAULT '',
side VARCHAR(4) NOT NULL, -- buy/sell
qty INTEGER NOT NULL,
price INTEGER NOT NULL,
status VARCHAR(16) DEFAULT 'pending', -- pending/confirmed/cancelled/filled/expired
reason TEXT DEFAULT '',
signal_score DOUBLE PRECISION DEFAULT 0,
target_price INTEGER DEFAULT 0,
stop_loss INTEGER DEFAULT 0,
proposed_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_at TIMESTAMPTZ,
filled_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '6 hour'),
external_order_id VARCHAR(40), -- KIS/키움 주문번호 (다음 단계)
is_paper BOOLEAN DEFAULT true
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_orders_status ON trading_orders(status)")
await conn.execute("""
CREATE TABLE IF NOT EXISTS trading_daily_pnl (
dt DATE PRIMARY KEY,
start_capital BIGINT DEFAULT 0,
end_capital BIGINT DEFAULT 0,
realized_pnl BIGINT DEFAULT 0,
unrealized_pnl BIGINT DEFAULT 0,
trades_count INTEGER DEFAULT 0,
halted BOOLEAN DEFAULT false,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""")
# 자동매매 안전 설정 — 보수적 기본값
TRADE_SETTINGS = {
"enabled": True, # 자동매매 ON/OFF 토글
"auto_execute": True, # 제안 즉시 자동 체결(모의). False면 텔레그램 버튼 승인 대기
"max_position_pct": 10.0, # 종목당 자본 ≤ 10%
"daily_loss_limit_pct": -3.0, # 일일 손실 -3% 도달 시 매매 중단
"max_orders_per_day": 10,
"min_conviction": 4, # Gemini 확신도 ≥4
"require_agreement": True, # 두 LLM 일치 필수
"min_diagnosis_score": 3, # 5가지 진단 ≥3
"default_capital": 10_000_000,
# 매도 신호 임계값
"stop_loss_pct": -8.0,
"take_profit_pct": 15.0,
"rsi_overbought": 75, # RSI ≥75 매도 신호
}
async def evaluate_buy_signal(conn, code: str) -> dict:
"""매수 신호 종합 평가 — 모든 가드 통과해야 매수 제안.
반환: {ok: bool, reasons: [...], score: float, target_price, stop_loss, ...}
"""
reasons_pass = []
reasons_fail = []
# 1. 추천 등급 = 강력매수
sc = await conn.fetchrow("""
SELECT total_score, recommendation, buy_votes, sell_votes
FROM stock_scores
WHERE stock_code=$1 AND score_date=(SELECT MAX(score_date) FROM stock_scores)
""", code)
if not sc:
return {"ok": False, "reasons_fail": ["퀀트 점수 없음"]}
if sc["recommendation"] != "강력매수":
reasons_fail.append(f"등급={sc['recommendation']} (강력매수만 허용)")
else:
reasons_pass.append(f"강력매수 {sc['total_score']:.0f}")
# 2. Hybrid LLM 분석 — 일치 + conviction
da = await conn.fetchrow("""
SELECT recommendation, gemini_recommendation, agreement,
gemini_conviction, gemini_target_price, gemini_stop_loss
FROM deep_analysis
WHERE stock_code=$1 AND analysis_date=CURRENT_DATE
""", code)
if not da:
reasons_fail.append("당일 LLM 분석 없음 (먼저 /deep 호출)")
else:
if TRADE_SETTINGS["require_agreement"] and not da["agreement"]:
reasons_fail.append("EXAONE/Gemini 의견 불일치")
else:
reasons_pass.append("두 LLM 일치")
if (da["gemini_conviction"] or 0) < TRADE_SETTINGS["min_conviction"]:
reasons_fail.append(f"Gemini 확신도 {da['gemini_conviction']}/5 (< {TRADE_SETTINGS['min_conviction']})")
else:
reasons_pass.append(f"Gemini 확신도 {da['gemini_conviction']}/5")
if da["gemini_recommendation"] not in ("강력매수", "매수"):
reasons_fail.append(f"Gemini 판단={da['gemini_recommendation']}")
else:
reasons_pass.append(f"Gemini {da['gemini_recommendation']}")
# 3. 단기 가격 추세 — 떨어지는 칼날 회피
try:
ret_5d, ret_20d = await calc_short_returns(conn, code)
if ret_5d <= -5 or ret_20d <= -10:
reasons_fail.append(f"단기 약세 (5d {ret_5d:+.1f}% / 20d {ret_20d:+.1f}%)")
else:
reasons_pass.append(f"단기 추세 (5d {ret_5d:+.1f}%)")
except Exception:
pass
# 4. 시장 레짐 — 약세장 회피
regime = await conn.fetchval(
"SELECT regime FROM market_regime ORDER BY dt DESC LIMIT 1")
if regime == "약세":
reasons_fail.append(f"시장 약세장 회피")
elif regime:
reasons_pass.append(f"시장 {regime}")
# 5. 일일 손실 한도 — 도달 시 매매 중단
pnl = await conn.fetchrow(
"SELECT halted FROM trading_daily_pnl WHERE dt=CURRENT_DATE")
if pnl and pnl["halted"]:
reasons_fail.append("일일 손실 한도 도달 — 매매 중단")
# 6. 일일 주문 한도
today_orders = await conn.fetchval("""
SELECT COUNT(*) FROM trading_orders
WHERE proposed_at::date = CURRENT_DATE AND status != 'cancelled'
""") or 0
if today_orders >= TRADE_SETTINGS["max_orders_per_day"]:
reasons_fail.append(f"일일 주문 한도 {today_orders}/{TRADE_SETTINGS['max_orders_per_day']}")
# 7. 이미 보유 중이면 재매수 제한
already = await conn.fetchval(
"SELECT id FROM user_portfolio WHERE stock_code=$1 AND active=true", code)
if already:
reasons_fail.append(f"이미 보유 중 (id={already})")
cur_price = await conn.fetchval(
"SELECT price FROM stock_technical WHERE stock_code=$1", code) or 0
target = (da["gemini_target_price"] if da else 0) or 0
sl = (da["gemini_stop_loss"] if da else 0) or 0
return {
"ok": len(reasons_fail) == 0,
"code": code,
"current_price": int(cur_price),
"target_price": int(target),
"stop_loss": int(sl),
"score": sc["total_score"] if sc else 0,
"reasons_pass": reasons_pass,
"reasons_fail": reasons_fail,
}
async def propose_buy_order(conn, code: str, capital: int = None) -> dict:
"""매수 신호 평가 통과 시 trading_orders에 pending 주문 등록 + 텔레그램 confirm 메시지."""
if capital is None:
capital = TRADE_SETTINGS["default_capital"]
sig = await evaluate_buy_signal(conn, code)
if not sig["ok"]:
return {"ok": False, "code": code, "reasons_fail": sig["reasons_fail"]}
price = sig["current_price"]
if price <= 0:
return {"ok": False, "reasons_fail": ["현재가 0"]}
# 종목당 max_position_pct 만큼 매수 (수량 1주 미만이면 매수 불가)
position = int(capital * TRADE_SETTINGS["max_position_pct"] / 100)
qty = position // price
if qty < 1:
return {"ok": False, "reasons_fail": [f"종목당 한도({position:,}원) < 1주 가격({price:,}원)"]}
name = await conn.fetchval(
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) or code
reason = " · ".join(sig["reasons_pass"][:5])
row = await conn.fetchrow("""
INSERT INTO trading_orders (
stock_code, stock_name, side, qty, price, status, reason,
signal_score, target_price, stop_loss, is_paper)
VALUES ($1,$2,'buy',$3,$4,'pending',$5,$6,$7,$8,true)
RETURNING id, expires_at
""", code, name, qty, price, reason, sig["score"], sig["target_price"], sig["stop_loss"])
# 텔레그램 confirm 메시지 + 버튼
msg = (
f"🚨 <b>매수 신호 — 승인 대기 (#{row['id']})</b>\n\n"
f"<b>{name}</b> ({code})\n"
f"수량: {qty}× {price:,}원 = {qty * price:,}\n"
f"목표가: {sig['target_price']:,}원 / 손절: {sig['stop_loss']:,}\n\n"
f"<b>✅ 통과 신호</b>\n" + "\n".join(f"{r}" for r in sig["reasons_pass"]) +
f"\n\n<i>6시간 내 응답 없으면 자동 만료</i>"
)
await send_telegram(msg, reply_markup=order_inline_buttons(row["id"], side="buy"))
return {"ok": True, "order_id": row["id"], "code": code, "qty": qty,
"price": price, "expires_at": row["expires_at"].isoformat()}
@app.post("/trade/propose/{code}")
async def trade_propose(code: str,
capital: int = Query(default=10_000_000, ge=1_000_000)):
"""수동 매수 신호 평가 + confirm 요청 (자동 cron으로도 호출됨)."""
async with pg_pool.acquire() as conn:
return await propose_buy_order(conn, code, capital)
@app.post("/trade/cancel/{order_id}")
async def trade_cancel(order_id: int):
async with pg_pool.acquire() as conn:
await conn.execute("""
UPDATE trading_orders SET status='cancelled'
WHERE id=$1 AND status='pending'
""", order_id)
return {"status": "cancelled", "order_id": order_id}
@app.get("/trade/orders")
async def trade_orders_list(status: str = Query(default=""),
days: int = Query(default=7)):
async with pg_pool.acquire() as conn:
if status:
rows = await conn.fetch("""
SELECT * FROM trading_orders
WHERE status=$1 AND proposed_at >= NOW() - $2::int * INTERVAL '1 day'
ORDER BY proposed_at DESC LIMIT 50
""", status, days)
else:
rows = await conn.fetch("""
SELECT * FROM trading_orders
WHERE proposed_at >= NOW() - $1::int * INTERVAL '1 day'
ORDER BY proposed_at DESC LIMIT 50
""", days)
return [dict(r) for r in rows]
@app.post("/trade/scan")
async def trade_scan(capital: int = Query(default=10_000_000),
auto_propose: bool = Query(default=False)):
"""모든 강력매수 종목 평가 → 통과 종목 표시 (또는 자동 proposal)."""
async with pg_pool.acquire() as conn:
codes = [r["stock_code"] for r in await conn.fetch("""
SELECT stock_code FROM stock_scores
WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
AND recommendation='강력매수'
ORDER BY total_score DESC LIMIT 10
""")]
results = []
for c in codes:
sig = await evaluate_buy_signal(conn, c)
results.append({"code": c, **sig})
if auto_propose and sig["ok"]:
await propose_buy_order(conn, c, capital)
return {"evaluated": len(results), "results": results}
@app.get("/trade/settings")
async def trade_settings_get():
return {"current": TRADE_SETTINGS}
async def evaluate_sell_signal(conn, code: str) -> dict:
"""보유 종목 매도 신호 평가 — 손절·익절·등급 하락 도달 시 매도."""
pos = await conn.fetchrow("""
SELECT id, stock_name, buy_price, qty FROM user_portfolio
WHERE stock_code=$1 AND active=true LIMIT 1
""", code)
if not pos:
return {"ok": False, "reasons_fail": ["보유 아님"]}
cur = await conn.fetchval(
"SELECT price FROM stock_technical WHERE stock_code=$1", code) or 0
if cur <= 0:
return {"ok": False, "reasons_fail": ["현재가 0"]}
buy = pos["buy_price"]
pnl_pct = (cur - buy) / buy * 100
reasons = []
sig_type = None
# 1. 손절선 도달
if pnl_pct <= TRADE_SETTINGS["stop_loss_pct"]:
reasons.append(f"손절선 도달 ({pnl_pct:+.1f}%)")
sig_type = "stop_loss"
# 2. 익절선 도달
elif pnl_pct >= TRADE_SETTINGS["take_profit_pct"]:
reasons.append(f"익절선 도달 ({pnl_pct:+.1f}%)")
sig_type = "take_profit"
# 3. 등급 강력매도/매도관심으로 하락
sc = await conn.fetchrow("""
SELECT recommendation, total_score FROM stock_scores
WHERE stock_code=$1 AND score_date=(SELECT MAX(score_date) FROM stock_scores)
""", code)
if sc and sc["recommendation"] in ("강력매도", "매도관심"):
reasons.append(f"등급 하락 ({sc['recommendation']} {sc['total_score']:.0f}점)")
sig_type = sig_type or "grade_drop"
# 4. Gemini 매도 판단
da = await conn.fetchrow("""
SELECT gemini_recommendation, gemini_conviction FROM deep_analysis
WHERE stock_code=$1 AND analysis_date=CURRENT_DATE
""", code)
if da and da["gemini_recommendation"] in ("강력매도", "매도") and (da["gemini_conviction"] or 0) >= 4:
reasons.append(f"Gemini 매도 {da['gemini_conviction']}/5")
sig_type = sig_type or "llm_sell"
# 5. RSI 과매수 (≥75) — 단기 과열 정점 회피
rsi = await conn.fetchval(
"SELECT rsi FROM stock_technical WHERE stock_code=$1", code)
if rsi is not None and rsi >= TRADE_SETTINGS["rsi_overbought"]:
reasons.append(f"RSI 과매수 ({rsi:.0f}{TRADE_SETTINGS['rsi_overbought']})")
sig_type = sig_type or "rsi_overbought"
# 6. 외국인 5일 누적 순매도 우세
f_flow = await conn.fetchval("""
SELECT COALESCE(SUM(foreign_net), 0) FROM stock_ohlcv
WHERE stock_code=$1 AND dt >= CURRENT_DATE - INTERVAL '7 days'
""", code)
if f_flow is not None and int(f_flow) < -1_000_000_000: # -10억 이상 순매도
reasons.append(f"외국인 5일 매도 {f_flow/1e8:+.0f}")
sig_type = sig_type or "foreign_sell"
return {
"ok": bool(sig_type), "type": sig_type,
"current_price": int(cur), "buy_price": buy, "qty": pos["qty"],
"pnl_pct": round(pnl_pct, 2),
"pnl_won": (cur - buy) * pos["qty"],
"reasons": reasons, "portfolio_id": pos["id"], "stock_name": pos["stock_name"],
}
async def propose_sell_order(conn, code: str) -> dict:
sig = await evaluate_sell_signal(conn, code)
if not sig["ok"]:
return {"ok": False, "code": code, **sig}
reason = " · ".join(sig["reasons"])
row = await conn.fetchrow("""
INSERT INTO trading_orders (
stock_code, stock_name, side, qty, price, status, reason,
is_paper)
VALUES ($1,$2,'sell',$3,$4,'pending',$5,true)
RETURNING id
""", code, sig["stock_name"], sig["qty"], sig["current_price"], reason)
icon = {"stop_loss": "🛑", "take_profit": "💰", "grade_drop": "⬇️", "llm_sell": "🤖"}.get(sig["type"], "🔻")
msg = (
f"{icon} <b>매도 신호 — 승인 대기 (#{row['id']})</b>\n\n"
f"<b>{sig['stock_name']}</b> ({code})\n"
f"{sig['qty']}주 @ 매수 {sig['buy_price']:,}원 → 현재 {sig['current_price']:,}\n"
f"손익: <b>{sig['pnl_pct']:+.2f}%</b> ({sig['pnl_won']:+,}원)\n\n"
f"📌 사유: " + " / ".join(sig["reasons"])
)
await send_telegram(msg, reply_markup=order_inline_buttons(row["id"], side="sell"))
return {"ok": True, "order_id": row["id"], "code": code,
"qty": sig["qty"], "price": sig["current_price"], "type": sig["type"]}
@app.post("/trade/sell-scan")
async def trade_sell_scan():
"""보유 종목 매도 신호 일괄 평가 + 자동 propose."""
async with pg_pool.acquire() as conn:
codes = [r["stock_code"] for r in await conn.fetch(
"SELECT stock_code FROM user_portfolio WHERE active=true")]
results = []
for c in codes:
sig = await evaluate_sell_signal(conn, c)
if sig["ok"]:
results.append(await propose_sell_order(conn, c))
return {"checked": len(codes), "proposed": len(results), "results": results}
async def _fill_order(conn, order_id: int, auto: bool = False) -> dict:
"""주문 체결(모의=DB 기록, 실제 브로커 전송 없음). auto=True면 자동매매 체결."""
order = await conn.fetchrow(
"SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id)
if not order:
return {"error": "주문 없음 또는 이미 처리됨"}
if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc):
await conn.execute(
"UPDATE trading_orders SET status='expired' WHERE id=$1", order_id)
return {"error": "주문 만료"}
tag = "자동체결" if auto else "체결"
if order["side"] == "buy":
await conn.execute("""
INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
VALUES ($1, $2, $3, $4, $5)
""", order["stock_code"], order["stock_name"], order["price"],
order["qty"], f"자동매매 #{order_id} (모의)")
msg = (f"✅ <b>매수 {tag} #{order_id}</b> (모의)\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}")
else: # sell
held = await conn.fetchrow("""
SELECT id, buy_price, qty FROM user_portfolio
WHERE stock_code=$1 AND active=true LIMIT 1
""", order["stock_code"])
realized = 0
if held:
realized = (order["price"] - held["buy_price"]) * order["qty"]
await conn.execute(
"UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
await conn.execute("""
INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count)
VALUES (CURRENT_DATE, $1, 1)
ON CONFLICT (dt) DO UPDATE SET
realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl,
trades_count = trading_daily_pnl.trades_count + 1,
updated_at = NOW()
""", realized)
sign = "🟢" if realized > 0 else "🔴"
msg = (f"{sign} <b>매도 {tag} #{order_id}</b> (모의)\n"
f"{order['stock_name']}({order['stock_code']}) "
f"{order['qty']}× {order['price']:,}\n"
f"실현손익: {realized:+,}")
await conn.execute("""
UPDATE trading_orders
SET status='filled', confirmed_at=NOW(), filled_at=NOW()
WHERE id=$1
""", order_id)
await send_telegram(msg)
return {"status": "filled", "order_id": order_id}
@app.post("/trade/confirm/{order_id}")
async def trade_confirm_v2(order_id: int): # noqa: F811 (이전 정의 override)
"""매수/매도 confirm 통합 처리 (수동 버튼)."""
async with pg_pool.acquire() as conn:
return await _fill_order(conn, order_id, auto=False)
@app.get("/trade/history")
async def trade_history(days: int = Query(default=30, ge=1, le=365)):
"""모의매매 이력 + 손익 — 체결내역·일별손익·보유 평가손익."""
async with pg_pool.acquire() as conn:
fills = await conn.fetch("""
SELECT stock_code, stock_name, side, qty, price, filled_at
FROM trading_orders
WHERE status='filled' AND filled_at >= CURRENT_DATE - ($1::int)
ORDER BY filled_at DESC
""", days)
daily = await conn.fetch("""
SELECT dt, realized_pnl, unrealized_pnl, trades_count, halted
FROM trading_daily_pnl WHERE dt >= CURRENT_DATE - ($1::int) ORDER BY dt DESC
""", days)
holds = await conn.fetch("""
SELECT p.stock_code, p.stock_name, p.buy_price, p.qty,
(SELECT price FROM stock_technical WHERE stock_code=p.stock_code) cur
FROM user_portfolio p WHERE active=true
""")
realized = sum((r["realized_pnl"] or 0) for r in daily)
holdings, unreal = [], 0
for h in holds:
cur = h["cur"] or h["buy_price"]
pl = int((cur - h["buy_price"]) * h["qty"]) if h["buy_price"] else 0
unreal += pl
holdings.append({"code": h["stock_code"], "name": h["stock_name"],
"buy_price": h["buy_price"], "qty": h["qty"], "cur_price": cur,
"pl": pl, "pl_pct": round((cur / h["buy_price"] - 1) * 100, 1) if h["buy_price"] else 0})
return {
"realized_pnl": realized, "unrealized_pnl": unreal, "total_pnl": realized + unreal,
"fill_count": len(fills),
"holdings": holdings,
"daily_pnl": [{"dt": str(r["dt"]), "realized": r["realized_pnl"],
"unrealized": r["unrealized_pnl"], "trades": r["trades_count"],
"halted": r["halted"]} for r in daily],
"fills": [{"code": f["stock_code"], "name": f["stock_name"], "side": f["side"],
"qty": f["qty"], "price": f["price"], "at": str(f["filled_at"])} for f in fills],
}
async def auto_trade_scan_job():
"""5분마다: 매수 신호 + 매도 신호 자동 스캔. 한도 도달 시 자동 halt."""
if not TRADE_SETTINGS.get("enabled", True):
return
try:
async with pg_pool.acquire() as conn:
pnl = await conn.fetchrow(
"SELECT realized_pnl, halted FROM trading_daily_pnl WHERE dt=CURRENT_DATE")
if pnl and pnl["halted"]:
return # 이미 중단된 날
capital = TRADE_SETTINGS["default_capital"]
realized = (pnl["realized_pnl"] if pnl else 0) or 0
# 미실현 손익까지 합산. 기존엔 realized만 봐서 ① 평가손실이 아무리 커도
# 매도 전엔 halt가 안 걸리고 ② 매도 없는 날은 pnl row 자체가 없어 체크를 건너뛰는
# 구멍이 있었음 → 보유 평가손익을 더해 포트폴리오 기준으로 판단.
unreal = await conn.fetchval("""
SELECT COALESCE(SUM((st.price - p.buy_price) * p.qty), 0)
FROM user_portfolio p
JOIN stock_technical st ON st.stock_code = p.stock_code
WHERE p.active = true AND p.buy_price > 0 AND st.price > 0
""") or 0
loss_pct = (realized + unreal) / capital * 100
if loss_pct <= TRADE_SETTINGS["daily_loss_limit_pct"]:
await conn.execute("""
INSERT INTO trading_daily_pnl (dt, unrealized_pnl, halted)
VALUES (CURRENT_DATE, $1, true)
ON CONFLICT (dt) DO UPDATE SET
unrealized_pnl=$1, halted=true, updated_at=NOW()
""", int(unreal))
await send_telegram(
f"🛑 <b>일일 손실 한도 도달</b>\n"
f"실현+평가 손실 {loss_pct:.2f}% ≤ -{abs(TRADE_SETTINGS['daily_loss_limit_pct'])}%\n"
f"(실현 {realized:+,}원 / 평가 {int(unreal):+,}원)\n"
f"오늘 자동매매 중단됨"
)
return
# 매도 스캔 먼저 (보유 종목 리스크 관리 우선)
sell_codes = [r["stock_code"] for r in await conn.fetch(
"SELECT stock_code FROM user_portfolio WHERE active=true")]
for c in sell_codes:
sig = await evaluate_sell_signal(conn, c)
if sig["ok"]:
# 같은 종목 pending 매도 주문 있는지 체크 (중복 방지)
dup = await conn.fetchval("""
SELECT id FROM trading_orders
WHERE stock_code=$1 AND side='sell' AND status='pending'
""", c)
if not dup:
await propose_sell_order(conn, c)
# 매수 스캔
buy_codes = [r["stock_code"] for r in await conn.fetch("""
SELECT stock_code FROM stock_scores
WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
AND recommendation='강력매수'
ORDER BY total_score DESC LIMIT 5
""")]
for c in buy_codes:
dup = await conn.fetchval("""
SELECT id FROM trading_orders
WHERE stock_code=$1 AND side='buy'
AND proposed_at::date = CURRENT_DATE
AND status IN ('pending','confirmed','filled')
""", c)
if dup:
continue
sig = await evaluate_buy_signal(conn, c)
if sig["ok"]:
await propose_buy_order(conn, c)
# 자동실행(모의): auto_execute면 오늘 제안된 pending 주문 즉시 체결
if TRADE_SETTINGS.get("auto_execute"):
pend = await conn.fetch(
"SELECT id FROM trading_orders WHERE status='pending' "
"AND proposed_at::date=CURRENT_DATE")
for p in pend:
await _fill_order(conn, p["id"], auto=True)
logger.info("auto_trade_scan.done")
except Exception as e:
logger.error("auto_trade_scan.err", error=str(e))
async def update_daily_pnl_job():
"""매일 16:00 — unrealized PNL 갱신 (보유 종목 평가손익)."""
try:
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT p.stock_code, p.buy_price, p.qty,
(SELECT price FROM stock_technical WHERE stock_code=p.stock_code) AS cur
FROM user_portfolio p WHERE active=true
""")
unrealized = sum(int(((r["cur"] or 0) - r["buy_price"]) * r["qty"]) for r in rows)
await conn.execute("""
INSERT INTO trading_daily_pnl (dt, unrealized_pnl)
VALUES (CURRENT_DATE, $1)
ON CONFLICT (dt) DO UPDATE SET
unrealized_pnl = EXCLUDED.unrealized_pnl, updated_at = NOW()
""", unrealized)
logger.info("daily_pnl.updated", unrealized=unrealized)
except Exception as e:
logger.error("daily_pnl.err", error=str(e))
@app.post("/trade/auto-scan")
async def trade_auto_scan_trigger():
asyncio.create_task(auto_trade_scan_job())
return {"status": "started"}
async def expire_stale_orders_job():
"""매 30분 — pending 상태 + expires_at 지난 주문 자동 expired 처리."""
try:
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
UPDATE trading_orders SET status='expired'
WHERE status='pending' AND expires_at < NOW()
RETURNING id, stock_name, side
""")
for r in rows:
logger.info("order.expired", id=r["id"], side=r["side"])
except Exception as e:
logger.warning("expire_orders.err", error=str(e))
@app.post("/trade/autotrade")
async def trade_autotrade_toggle(enabled: bool = Query(...)):
"""자동매매 ON/OFF 토글."""
TRADE_SETTINGS["enabled"] = enabled
await send_telegram(
f"⚙️ <b>자동매매 {'ON ✅' if enabled else 'OFF 🛑'}</b>\n"
f"<i>{'5분마다 신호 자동 스캔 작동 중' if enabled else '신호 스캔 일시 중단'}</i>"
)
return {"enabled": enabled}
@app.post("/trade/settings")
async def trade_settings_update(
max_position_pct: float | None = Query(default=None),
daily_loss_limit_pct: float | None = Query(default=None),
max_orders_per_day: int | None = Query(default=None),
min_conviction: int | None = Query(default=None),
require_agreement: bool | None = Query(default=None),
stop_loss_pct: float | None = Query(default=None),
take_profit_pct: float | None = Query(default=None),
rsi_overbought: int | None = Query(default=None),
default_capital: int | None = Query(default=None),
):
"""안전 설정 수정 — 자동매매 임계값 튜닝용."""
updated = {}
for k, v in [
("max_position_pct", max_position_pct),
("daily_loss_limit_pct", daily_loss_limit_pct),
("max_orders_per_day", max_orders_per_day),
("min_conviction", min_conviction),
("require_agreement", require_agreement),
("stop_loss_pct", stop_loss_pct),
("take_profit_pct", take_profit_pct),
("rsi_overbought", rsi_overbought),
("default_capital", default_capital),
]:
if v is not None:
TRADE_SETTINGS[k] = v
updated[k] = v
return {"updated": updated, "current": TRADE_SETTINGS}
@app.get("/system/status")
async def system_status():
"""전체 시스템 상태 한눈에."""
async with pg_pool.acquire() as conn:
today_analyses = await conn.fetchval("""
SELECT COUNT(*) FROM deep_analysis WHERE analysis_date=CURRENT_DATE
""")
today_cost = await conn.fetchval("""
SELECT COALESCE(SUM(llm_cost_krw), 0) FROM deep_analysis
WHERE analysis_date=CURRENT_DATE
""")
month_cost = await conn.fetchval("""
SELECT COALESCE(SUM(llm_cost_krw), 0) FROM deep_analysis
WHERE analysis_date >= date_trunc('month', CURRENT_DATE)
""")
latest_score_date = await conn.fetchval(
"SELECT MAX(score_date) FROM stock_scores")
scored_today = await conn.fetchval(
"SELECT COUNT(*) FROM stock_scores WHERE score_date=$1",
latest_score_date) if latest_score_date else 0
strong_buy = await conn.fetchval("""
SELECT COUNT(*) FROM stock_scores
WHERE score_date=$1 AND recommendation='강력매수'
""", latest_score_date) if latest_score_date else 0
portfolio_count = await conn.fetchval(
"SELECT COUNT(*) FROM user_portfolio WHERE active=true")
today_orders = await conn.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE status='pending') AS pending,
COUNT(*) FILTER (WHERE status='filled') AS filled,
COUNT(*) FILTER (WHERE status='cancelled') AS cancelled,
COUNT(*) FILTER (WHERE status='expired') AS expired
FROM trading_orders WHERE proposed_at::date = CURRENT_DATE
""")
pnl = await conn.fetchrow("""
SELECT realized_pnl, unrealized_pnl, halted FROM trading_daily_pnl
WHERE dt=CURRENT_DATE
""")
return {
"trading": {
"enabled": TRADE_SETTINGS.get("enabled", True),
"halted": bool(pnl and pnl["halted"]),
"realized_pnl_today": int((pnl["realized_pnl"] if pnl else 0) or 0),
"unrealized_pnl_today": int((pnl["unrealized_pnl"] if pnl else 0) or 0),
"orders_today": dict(today_orders) if today_orders else {},
},
"analysis": {
"today_analyses": today_analyses,
"today_llm_cost_krw": int(today_cost or 0),
"month_llm_cost_krw": int(month_cost or 0),
},
"scoring": {
"latest_score_date": str(latest_score_date) if latest_score_date else None,
"scored_today": scored_today,
"strong_buy_count": strong_buy,
},
"portfolio": {
"active_positions": portfolio_count,
},
"settings": TRADE_SETTINGS,
}
@app.get("/trade/pnl")
async def trade_pnl_view(days: int = Query(default=30)):
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT * FROM trading_daily_pnl
WHERE dt >= CURRENT_DATE - $1::int ORDER BY dt DESC
""", days)
return [dict(r) for r in rows]
async def weekly_performance_report():
"""매주 일요일 09:00 — 지난 7/30일 성과 리포트 텔레그램 발송.
자동매매 신뢰도 측정 핵심: 등급별 승률·알파·MDD 추적.
"""
try:
async with pg_pool.acquire() as conn:
# 등급별 30일 성과 (지난 90일 누적)
grade_rows = await conn.fetch("""
SELECT recommendation,
COUNT(*) AS n,
ROUND(AVG(return_7d)::numeric, 2) AS r7,
ROUND(AVG(return_30d)::numeric, 2) AS r30,
ROUND(AVG(alpha_30d)::numeric, 2) AS alpha30,
ROUND((COUNT(*) FILTER (WHERE return_30d > 0)::numeric /
NULLIF(COUNT(*) FILTER (WHERE return_30d IS NOT NULL), 0) * 100), 1) AS win30
FROM recommendation_performance
WHERE rec_date >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY recommendation
ORDER BY r30 DESC NULLS LAST
""")
# 이번 주 추천 종목 7일 성과
week = await conn.fetchrow("""
SELECT COUNT(*) AS n,
ROUND(AVG(return_7d)::numeric, 2) AS r7,
ROUND(AVG(alpha_7d)::numeric, 2) AS alpha7,
ROUND((COUNT(*) FILTER (WHERE return_7d > 0)::numeric /
NULLIF(COUNT(*) FILTER (WHERE return_7d IS NOT NULL), 0) * 100), 1) AS win7
FROM recommendation_performance
WHERE rec_date >= CURRENT_DATE - INTERVAL '7 days'
AND recommendation IN ('강력매수','매수관심')
""")
# 보유 종목 평가 (있으면)
portfolio = await conn.fetch("""
SELECT p.stock_code, p.stock_name, p.buy_price, p.qty,
(SELECT price FROM stock_technical WHERE stock_code=p.stock_code) AS cur
FROM user_portfolio p WHERE active=true
""")
lines = [f"📊 <b>주간 성과 리포트 ({date.today()})</b>\n"]
lines.append("<b>📈 시스템 등급별 성과 (90일)</b>")
for r in grade_rows:
sign = "🟢" if (r["alpha30"] or 0) > 0 else "🔴" if (r["alpha30"] or 0) < -3 else "🟡"
lines.append(
f"{sign} {r['recommendation']}({r['n']}건): "
f"30일 {r['r30']}% / 알파 {r['alpha30']}% / 승률 {r['win30']}%"
)
if week and week["n"]:
lines.append(f"\n<b>📅 이번 주 매수 추천 ({week['n']}건)</b>")
lines.append(f" 7일 평균 {week['r7']}% / 알파 {week['alpha7']}% / 승률 {week['win7']}%")
if portfolio:
lines.append(f"\n<b>👜 보유 종목</b>")
total_buy = 0
total_cur = 0
for p in portfolio:
cur = int(p["cur"] or 0)
buy = p["buy_price"]
if not cur or not buy:
continue
pnl_pct = (cur - buy) / buy * 100
v_buy = buy * p["qty"]
v_cur = cur * p["qty"]
total_buy += v_buy
total_cur += v_cur
lines.append(f" {p['stock_name']}({p['stock_code']}): {pnl_pct:+.1f}% ({v_cur - v_buy:+,}원)")
if total_buy:
tot_pnl = (total_cur - total_buy) / total_buy * 100
lines.append(f" <b>합계 {tot_pnl:+.2f}% ({total_cur - total_buy:+,}원)</b>")
lines.append(f"\n<i>알파>0이면 시스템이 시장보다 잘함. 알파<-3이면 자동매매 부적합.</i>")
await send_telegram("\n".join(lines))
logger.info("weekly_report.done")
except Exception as e:
logger.error("weekly_report.err", error=str(e))
async def monthly_llm_comparison():
"""매월 1일 — Gemini vs EXAONE vs 퀀트 정확도 비교 리포트 텔레그램."""
try:
async with pg_pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT
COUNT(*) FILTER (WHERE verified_at IS NOT NULL) AS verified,
COUNT(*) FILTER (WHERE gemini_correct=true) AS g_ok,
COUNT(*) FILTER (WHERE gemini_correct=false) AS g_no,
COUNT(*) FILTER (WHERE exaone_correct=true) AS e_ok,
COUNT(*) FILTER (WHERE exaone_correct=false) AS e_no,
AVG(realized_return_30d) FILTER (WHERE gemini_recommendation IN ('강력매수','매수')) AS g_buy_ret,
AVG(realized_return_30d) FILTER (WHERE recommendation IN ('강력매수','매수')) AS e_buy_ret,
COUNT(*) FILTER (WHERE agreement=true) AS agreed,
COUNT(*) FILTER (WHERE agreement=false) AS disagreed,
SUM(llm_cost_krw) AS cost
FROM deep_analysis
WHERE analysis_date >= CURRENT_DATE - INTERVAL '30 days'
""")
g_tot = (row["g_ok"] or 0) + (row["g_no"] or 0)
e_tot = (row["e_ok"] or 0) + (row["e_no"] or 0)
g_acc = round(row["g_ok"] / g_tot * 100, 1) if g_tot else None
e_acc = round(row["e_ok"] / e_tot * 100, 1) if e_tot else None
lines = [f"🤖 <b>LLM 정확도 월간 리포트 ({date.today()})</b>\n"]
lines.append(f"검증된 분석: {row['verified']}건 (지난 30일)")
lines.append(f"\n<b>정확도</b>")
lines.append(f" 🌟 Gemini: {g_acc}% ({row['g_ok']}/{g_tot})")
lines.append(f" 📘 EXAONE: {e_acc}% ({row['e_ok']}/{e_tot})")
lines.append(f"\n<b>매수 종목 30일 실제 수익률</b>")
if row["g_buy_ret"] is not None:
lines.append(f" Gemini 매수: {row['g_buy_ret']:.2f}%")
if row["e_buy_ret"] is not None:
lines.append(f" EXAONE 매수: {row['e_buy_ret']:.2f}%")
lines.append(f"\n<b>의견 일치율</b>")
lines.append(f" 일치: {row['agreed']}건 / 갈림: {row['disagreed']}")
lines.append(f"\n💰 Gemini 비용: {int(row['cost'] or 0)}")
# 정확도 기반 권고
if g_acc and g_acc >= 70:
lines.append(f"\n✅ Gemini 정확도 ≥70% — 자동매매 검토 가능 수준")
elif g_acc and g_acc >= 60:
lines.append(f"\n🟡 Gemini 정확도 60~70% — 모의매매로 검증 권장")
else:
lines.append(f"\n🔴 Gemini 정확도 < 60% — 자동매매 부적합, 데이터 누적 필요")
await send_telegram("\n".join(lines))
logger.info("monthly_llm_report.done")
except Exception as e:
logger.error("monthly_llm_report.err", error=str(e))
@app.post("/performance/weekly-report")
async def trigger_weekly_report():
asyncio.create_task(weekly_performance_report())
return {"status": "started"}
@app.post("/performance/monthly-llm")
async def trigger_monthly_llm():
asyncio.create_task(monthly_llm_comparison())
return {"status": "started"}
@app.get("/performance/simulation")
async def trading_simulation(days: int = Query(default=90, ge=14, le=365),
kinds: str = Query(default="강력매수"),
capital: int = Query(default=10_000_000, ge=1_000_000),
stop_loss_pct: float = Query(default=-8.0),
take_profit_pct: float = Query(default=15.0),
trade_cost_pct: float = Query(default=0.3),
max_position_pct: float = Query(default=10.0)):
"""모의 자동매매 시뮬레이션 (거래비용·손절·익절 적용).
실전과 가까운 현실적 수익률 계산 → 자동매매 진짜 신뢰도.
"""
kind_list = [k.strip() for k in kinds.split(",") if k.strip()]
async with pg_pool.acquire() as conn:
recs = await conn.fetch("""
SELECT p.stock_code, p.rec_date, p.entry_price, p.return_30d, p.recommendation,
p.price_7d, p.price_30d
FROM recommendation_performance p
WHERE p.rec_date >= CURRENT_DATE - $1::int
AND p.recommendation = ANY($2::text[])
AND p.entry_price > 0 AND p.return_30d IS NOT NULL
ORDER BY p.rec_date
""", days, kind_list)
cash = float(capital)
wins = losses = stop_hits = profit_hits = full_holds = 0
pnl_total = 0.0
trade_log = []
# 단순화: 추천일 진입 → 30일 보유 동안 손절/익절 도달 시 청산
# price_7d, price_30d만 알지만 손절·익절 확률적 적용 (return_30d가 음수면 손절 가능성 ↑)
for r in recs:
ret_30d = r["return_30d"]
# 종목당 max_position_pct만큼 투자 (Kelly 단순화)
position = min(cash * max_position_pct / 100, cash)
if position <= 0: break
# 시뮬레이션: 진입 후 30일 동안
# 가장 보수적 가정: stop_loss < 30d 최저점이면 손절, take_profit < 30d 최고점이면 익절
# price_7d / price_30d만 있으므로 근사:
# - 종목이 결국 -8% 이하 마감했으면 손절(-8% 가정)
# - 30일 동안 +15% 이상 갔으면 익절(+15% 가정)
actual_ret = ret_30d
if ret_30d <= stop_loss_pct:
actual_ret = stop_loss_pct
stop_hits += 1
elif ret_30d >= take_profit_pct:
actual_ret = take_profit_pct
profit_hits += 1
else:
full_holds += 1
# 거래비용 (왕복 0.3%)
net_ret = actual_ret - trade_cost_pct
pnl = position * net_ret / 100
pnl_total += pnl
cash += pnl
if net_ret > 0: wins += 1
else: losses += 1
if len(trade_log) < 20:
trade_log.append({
"code": r["stock_code"], "date": str(r["rec_date"]),
"rec": r["recommendation"], "raw_ret_30d": ret_30d,
"actual_ret": round(actual_ret, 2), "pnl": round(pnl)
})
n = wins + losses
return {
"period_days": days, "kinds": kind_list,
"settings": {
"capital": capital, "stop_loss_pct": stop_loss_pct,
"take_profit_pct": take_profit_pct, "trade_cost_pct": trade_cost_pct,
"max_position_pct": max_position_pct,
},
"trades": n,
"wins": wins, "losses": losses,
"win_rate_pct": round(wins / n * 100, 1) if n else None,
"stop_loss_hits": stop_hits,
"take_profit_hits": profit_hits,
"full_holds": full_holds,
"final_capital": round(cash),
"total_pnl": round(pnl_total),
"total_return_pct": round(pnl_total / capital * 100, 2),
"annualized_return_pct": round(pnl_total / capital * (365 / days) * 100, 2) if days else None,
"sample_trades": trade_log[:10],
}
@app.get("/labels/calibration")
async def labels_calibration(days: int = Query(default=180, ge=30, le=730)):
"""확신도(conviction) calibration — 1~5 별 실제 정확도.
잘 calibration된 모델: conv5 → 정확도 ~90%, conv3 → ~60%, conv1 → ~20%
크게 어긋나면 LLM 확신도가 과대평가/과소평가 신호.
"""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT gemini_conviction AS conv,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE gemini_correct = true) AS hit,
COUNT(*) FILTER (WHERE gemini_correct = false) AS miss,
AVG(realized_return_30d) AS avg_ret
FROM deep_analysis
WHERE verified_at IS NOT NULL
AND analysis_date >= CURRENT_DATE - $1::int
GROUP BY gemini_conviction
ORDER BY gemini_conviction
""", days)
exa_rows = await conn.fetch("""
SELECT conviction AS conv,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE exaone_correct = true) AS hit
FROM deep_analysis
WHERE verified_at IS NOT NULL
AND analysis_date >= CURRENT_DATE - $1::int
GROUP BY conviction
ORDER BY conviction
""", days)
def _calib(r):
verified = (r["hit"] or 0) + (r["miss"] or 0) if "miss" in r else r["total"]
return {
"conviction": r["conv"],
"total": r["total"],
"hit": r["hit"],
"accuracy_pct": round(r["hit"] / verified * 100, 1) if verified else None,
"avg_return_30d": round(r["avg_ret"], 2) if "avg_ret" in r and r["avg_ret"] else None,
}
return {
"period_days": days,
"gemini_calibration": [_calib(r) for r in rows],
"exaone_calibration": [_calib(r) for r in exa_rows],
"interpretation": "확신도 ≥4의 정확도가 70% 미만이면 LLM이 과신 경향 → 임계값 보정 필요",
}
@app.get("/labels/catalyst-accuracy")
async def labels_catalyst_accuracy(days: int = Query(default=180, ge=30, le=730)):
"""catalyst별 정확도 — 실적/수주/배당/리스크/모멘텀 중 어느 catalyst가 잘 맞는지.
잘 맞는 catalyst 가중치 ↑ / 못 맞는 catalyst 가중치 ↓로 향후 자동 조정 가능.
"""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
WITH na AS (
SELECT DISTINCT ON (n.primary_stock, n.analyzed_at::date)
n.primary_stock, n.analyzed_at::date AS dt, n.catalyst, n.intensity
FROM news_analysis n
WHERE n.intensity >= 3 AND n.catalyst IS NOT NULL
AND n.analyzed_at::date >= CURRENT_DATE - $1::int
ORDER BY n.primary_stock, n.analyzed_at::date, n.intensity DESC
)
SELECT na.catalyst,
COUNT(*) AS news_count,
COUNT(da.id) AS analyzed,
COUNT(*) FILTER (WHERE da.gemini_correct = true) AS hit,
COUNT(*) FILTER (WHERE da.gemini_correct = false) AS miss,
AVG(da.realized_return_30d) AS avg_ret
FROM na
LEFT JOIN deep_analysis da
ON da.stock_code = na.primary_stock
AND da.analysis_date BETWEEN na.dt AND na.dt + INTERVAL '3 days'
AND da.verified_at IS NOT NULL
GROUP BY na.catalyst
HAVING COUNT(da.id) >= 3
ORDER BY COUNT(*) FILTER (WHERE da.gemini_correct = true) DESC
""", days)
return {
"period_days": days,
"by_catalyst": [
{"catalyst": r["catalyst"],
"news_count": r["news_count"],
"analyzed": r["analyzed"],
"hit": r["hit"], "miss": r["miss"],
"accuracy_pct": round(r["hit"] / (r["hit"] + r["miss"]) * 100, 1) if (r["hit"] + r["miss"]) > 0 else None,
"avg_return_30d": round(r["avg_ret"], 2) if r["avg_ret"] else None}
for r in rows
],
"interpretation": "정확도 높은 catalyst의 뉴스에 더 큰 가중치를 두는 게 합리적",
}
@app.get("/labels/training-export")
async def training_export(min_conviction: int = Query(default=3, ge=1, le=5),
verified_only: bool = Query(default=False),
require_agreement: bool = Query(default=False),
max_abs_return: float = Query(default=30.0, gt=0,
description="이상치 제거: |30d 수익률| 초과 케이스 배제 (시장 충격 noise)"),
dedup_same_stock_days: int = Query(default=7, ge=0,
description="같은 종목 N일 내 중복 분석 제거 (편향 방지)"),
limit: int = Query(default=10000, ge=1, le=50000)):
"""LoRA 파인튜닝용 JSONL 데이터셋 export — 노이즈 정제 파이프라인 포함.
포맷: {input: rag_context, target: {label, thesis}}
필터:
- verified_only=true: 30일 후 실제 수익률로 Gemini가 맞은 케이스만
- require_agreement=true: EXAONE+Gemini 일치 케이스만 (가장 신뢰)
- max_abs_return=30: 극단 수익률 케이스 제외 (시장 충격 외생 노이즈)
- dedup_same_stock_days=7: 같은 종목 일주일 내 중복 제거 (편향 방지)
"""
where = ["gemini_recommendation IS NOT NULL", "gemini_conviction >= $1"]
if verified_only:
where.append("gemini_correct = true")
if require_agreement:
where.append("agreement = true")
if max_abs_return > 0:
where.append(f"(realized_return_30d IS NULL OR ABS(realized_return_30d) <= {max_abs_return})")
sql = f"""
SELECT stock_code, analysis_date, rag_context,
recommendation AS exaone_rec, gemini_recommendation,
gemini_thesis, gemini_conviction,
gemini_target_price, gemini_stop_loss,
realized_return_30d, gemini_correct, agreement
FROM deep_analysis
WHERE {' AND '.join(where)}
ORDER BY analysis_date DESC LIMIT $2
"""
async with pg_pool.acquire() as conn:
rows = await conn.fetch(sql, min_conviction, limit)
total_raw = await conn.fetchval("""
SELECT COUNT(*) FROM deep_analysis WHERE gemini_recommendation IS NOT NULL
""")
# 같은 종목 N일 내 중복 제거 (시간순으로 후순위 제거)
seen: dict[str, date] = {}
samples = []
for r in rows:
last = seen.get(r["stock_code"])
if last and dedup_same_stock_days > 0 and (last - r["analysis_date"]).days < dedup_same_stock_days:
continue
seen[r["stock_code"]] = r["analysis_date"]
samples.append({
"input": r["rag_context"],
"target": {
"recommendation": r["gemini_recommendation"],
"conviction": r["gemini_conviction"],
"thesis": r["gemini_thesis"],
"target_price": r["gemini_target_price"],
"stop_loss": r["gemini_stop_loss"],
},
"meta": {
"stock_code": r["stock_code"],
"analysis_date": r["analysis_date"].isoformat(),
"realized_return_30d": r["realized_return_30d"],
"verified_correct": r["gemini_correct"],
"agreement": r["agreement"],
}
})
return {
"count": len(samples),
"filters": {
"min_conviction": min_conviction,
"verified_only": verified_only,
"require_agreement": require_agreement,
"max_abs_return": max_abs_return,
"dedup_same_stock_days": dedup_same_stock_days,
},
"noise_reduction": {
"raw_count": int(total_raw or 0),
"after_filters": len(rows),
"after_dedup": len(samples),
"removed_pct": round((1 - len(samples) / total_raw) * 100, 1) if total_raw else 0,
},
"samples": samples,
}