b3c5032f72
트레이딩 전문가 관점 로직 감사 후 라이브 추천·자동매매 영향 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>
7038 lines
332 KiB
Python
7038 lines
332 KiB
Python
"""
|
||
종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반)
|
||
- 펀더멘털 점수 (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 <코드> 명령")
|
||
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,
|
||
}
|