2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반)
|
|
|
|
|
|
- 펀더멘털 점수 (ROE, 영업이익률, 부채비율, PER/PBR) 30%
|
|
|
|
|
|
- 뉴스 감성 점수 25%
|
|
|
|
|
|
- 공시 점수 (DART) 15%
|
|
|
|
|
|
- 기술적 분석 점수 20%
|
|
|
|
|
|
- 가격 모멘텀 점수 10%
|
|
|
|
|
|
- 가치 필터: PER 0~40, 부채비율<80%, 영업이익>0, 시총>100억
|
|
|
|
|
|
- 매일 장 마감 후 자동 집계 + 텔레그램 알림
|
|
|
|
|
|
"""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
import asyncio, json, os, pickle
|
2026-05-20 22:54:17 +09:00
|
|
|
|
from datetime import datetime, date, timedelta, timezone
|
2026-06-02 01:22:23 +09:00
|
|
|
|
from typing import Optional, Literal
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
|
|
|
|
|
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
pg_pool: Optional[asyncpg.Pool] = None
|
|
|
|
|
|
redis_cl: Optional[aioredis.Redis] = None
|
|
|
|
|
|
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
|
|
|
|
|
|
|
|
|
|
|
# ── 텔레그램 알림 ──────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
async def send_telegram(msg: str, reply_markup: dict | None = None):
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if not TG_TOKEN or not TG_CHAT_ID:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
payload = {"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"}
|
|
|
|
|
|
if reply_markup:
|
|
|
|
|
|
payload["reply_markup"] = reply_markup
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async with httpx.AsyncClient() as c:
|
|
|
|
|
|
await c.post(
|
|
|
|
|
|
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
|
2026-06-02 01:22:23 +09:00
|
|
|
|
json=payload, timeout=10)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("telegram.err", error=str(e))
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
|
|
|
|
|
|
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}"},
|
|
|
|
|
|
]]}
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# ── 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)")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 사용자 보유 포트폴리오 — 기존 테이블에 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}")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
)
|
|
|
|
|
|
""")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 레짐/섹터 분리 학습용 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")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
async def _close_near(conn, code: str, target: date) -> Optional[float]:
|
|
|
|
|
|
"""target일 또는 그 직전 거래일(최대 7일 소급)의 종가. 주말·휴장일 보정용."""
|
|
|
|
|
|
row = await conn.fetchrow("""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
SELECT close_price FROM stock_ohlcv
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return None
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return None
|
|
|
|
|
|
return (e - s) / s * 100
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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}")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
for row in rows_7d:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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}")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
for row in rows_30d:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
임계값 + 다수공식 동의 강제 + 단기 약세 가드
|
|
|
|
|
|
- 강력매수: 점수 ≥70 AND 매수 ≥2 (가중치>0 공식 한정)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
- 매수관심: 점수 ≥40 AND 매수≥1 AND 매도<2
|
2026-06-02 01:22:23 +09:00
|
|
|
|
- 강력매도: 점수 ≤-60 OR 매도≥3
|
|
|
|
|
|
- 매도관심: 점수 ≤-30 OR 매도≥2
|
2026-05-20 21:33:56 +09:00
|
|
|
|
- 그 외: 관망
|
2026-06-02 01:22:23 +09:00
|
|
|
|
※ buy/sell_votes는 학습이 가중치 0으로 가지친 공식 제외하고 카운트됨.
|
|
|
|
|
|
|
|
|
|
|
|
단기 약세 가드: 펀더가 좋아도 최근 가격이 무너지면 등급 강등 (떨어지는 칼날 회피)
|
|
|
|
|
|
- 강력매수 → 매수관심: ret_5d ≤ -5% OR ret_20d ≤ -10%
|
|
|
|
|
|
- 매수관심 → 관망: ret_5d ≤ -8% OR ret_20d ≤ -15%
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 22:12:11 +09:00
|
|
|
|
def calc_short_score(short_data: list, rsi: float | None = None) -> tuple[float, str]:
|
|
|
|
|
|
"""공매도 점수 (-100~+100). 기본은 공매도 많을수록 패널티지만,
|
|
|
|
|
|
공매도 과다 + 과매도(RSI≤35)면 숏커버링 반등(스퀴즈) 기대로 가산 — 양방향."""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
2026-06-03 22:12:11 +09:00
|
|
|
|
# 숏스퀴즈 기대: 공매도 과다(거래비중≥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})"
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return max(-100.0, min(100.0, score)), reason
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── H1: 5년 재무 추세 점수 ────────────────────────────────
|
2026-05-25 00:48:39 +09:00
|
|
|
|
async def calc_trend_score(conn, stock_code: str, as_of: date | None = None) -> tuple[float, str]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
최근 5년치 사업보고서 ROE/영업이익률의 일관성·추세 점수 (-30~+30)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
버핏: 안정적이고 우상향하는 수익성 선호.
|
|
|
|
|
|
as_of=date면 그 시점 이전 공시된 보고서만 사용 (look-ahead bias 차단).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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]:
|
|
|
|
|
|
"""
|
2026-06-06 13:41:52 +09:00
|
|
|
|
Z'' 비제조업 모델의 2항 변형 (운전자본·이익잉여금 데이터 부재로 X1·X2 항 생략).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
Z_simple = 6.72*(EBIT/총자산) + 1.05*(시총/총부채)
|
2026-06-06 13:41:52 +09:00
|
|
|
|
|
|
|
|
|
|
※ 임계값 재보정: 원본 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 부도위험
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
2026-06-06 13:41:52 +09:00
|
|
|
|
if z >= 3.0:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return round(z, 2), "매수", f"Altman Z {z:.1f} (안전)"
|
2026-06-06 13:41:52 +09:00
|
|
|
|
if z >= 0.7:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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개월 가격 모멘텀) ──────────────────
|
2026-05-25 00:48:39 +09:00
|
|
|
|
async def calc_momentum(conn, stock_code: str, as_of: date | None = None) -> tuple[float, str, str]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
AQR 스타일 12-1개월 모멘텀: (P_t-21 / P_t-252) - 1
|
2026-05-25 00:48:39 +09:00
|
|
|
|
최근 1개월 제외(반전효과 회피)한 11개월 수익률.
|
|
|
|
|
|
as_of=date면 그 시점 이전 가격만 사용.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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) 전
|
2026-06-06 13:41:52 +09:00
|
|
|
|
# 12개월 전(거래일 252) 고정. closes[-1]을 쓰면 보유 데이터 길이(200~259)에 따라
|
|
|
|
|
|
# 룩백이 종목마다 달라져 모멘텀 비교가 불가능해짐 → 252봉으로 캡(부족 시 최장 사용).
|
|
|
|
|
|
p_year = closes[min(251, len(closes) - 1)][1]
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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) ────────────────────────────────
|
2026-05-25 00:48:39 +09:00
|
|
|
|
async def calc_amihud(conn, stock_code: str, as_of: date | None = None) -> tuple[float, str, str]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
Amihud (2002): ILLIQ = avg(|return| / 거래대금) × 1e9
|
|
|
|
|
|
소형주 비유동성 프리미엄 — 높을수록 알파 잠재력 ↑ but 거래 어려움
|
2026-05-25 00:48:39 +09:00
|
|
|
|
20일 평균 사용 (1년 미만 데이터에서도 작동).
|
|
|
|
|
|
as_of=date면 그 시점 이전 가격/거래량만 사용.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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) ──────────
|
2026-05-25 00:48:39 +09:00
|
|
|
|
async def calc_beta(conn, stock_code: str, days: int = 60, as_of: date | None = None) -> tuple[float, str, str]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
종목 일별 수익률 vs KOSPI 60일 회귀 베타
|
|
|
|
|
|
BAB(Betting Against Beta) 알파: 저베타 종목이 위험조정 후 우월
|
2026-05-25 00:48:39 +09:00
|
|
|
|
β < 0.7 매수 (저베타 알파), β > 1.5 매도 (고베타 위험), 그 사이 관망.
|
|
|
|
|
|
as_of=date면 그 시점 이전 가격만 사용.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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), "관망", ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 앙상블 보팅 (공식별 신호 다수결) ───────────────────────
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 앙상블 보팅 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]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
signals: {공식이름: '매수'/'매도'/'관망'}
|
2026-06-02 01:22:23 +09:00
|
|
|
|
weights: {공식이름: float} — 주어지면 가중치 > 0인 공식만 카운트(학습이 죽인 공식 제외).
|
|
|
|
|
|
graph는 학습 제외이므로 weights에 없어도 항상 카운트.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
returns: (요약문, 카운트 dict)
|
|
|
|
|
|
"""
|
|
|
|
|
|
counts = {"매수": 0, "매도": 0, "관망": 0}
|
2026-06-02 01:22:23 +09:00
|
|
|
|
for fname, s in signals.items():
|
|
|
|
|
|
if weights is not None and fname in ENSEMBLE_FORMULAS:
|
|
|
|
|
|
if weights.get(fname, 0.0) <= 0:
|
|
|
|
|
|
continue
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# ── 감성 평가 강화 모듈 (M4 + A/B/C/D/E) ──────────────────
|
|
|
|
|
|
import math as _math
|
|
|
|
|
|
|
|
|
|
|
|
# catalyst 가중치 (정규화된 라벨 기준)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
CATALYST_WEIGHTS = {
|
2026-05-20 22:54:17 +09:00
|
|
|
|
"실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4,
|
|
|
|
|
|
"M&A": 1.3, "신제품": 1.2, "규제": 1.3, "정책": 1.2,
|
|
|
|
|
|
"기타": 1.0, "모멘텀": 0.8,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# 세분 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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 사후 학습된 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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 22:54:17 +09:00
|
|
|
|
async def calc_news_score_weighted(
|
|
|
|
|
|
conn, stock_code: str, week_ago: date, now: datetime | None = None
|
|
|
|
|
|
) -> tuple[float, dict]:
|
|
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
종합 가중치 = catalyst × intensity × 시간감쇠 × 중복가중
|
|
|
|
|
|
× 출처신뢰도 × 제목강도 × LLM신뢰도 × 사후신뢰도(reliability)
|
|
|
|
|
|
× 사건시드여부(첫 뉴스만 풀가중·후속 0.30)
|
|
|
|
|
|
× stock_impacts 매핑(1뉴스 N종목 영향도)
|
|
|
|
|
|
→ -100~+100. 동시에 pos/neg/neutral 카운트 반환.
|
|
|
|
|
|
|
|
|
|
|
|
primary_stock 외에 stock_impacts에 등장하는 종목도 가중치 비례 점수 포함.
|
2026-05-20 22:54:17 +09:00
|
|
|
|
"""
|
|
|
|
|
|
now = now or datetime.now(timezone.utc)
|
|
|
|
|
|
week_ago_dt = datetime.combine(week_ago, datetime.min.time(), tzinfo=timezone.utc)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# primary_stock=stock OR stock_impacts에 stock_code가 키로 등장하는 뉴스 전체
|
|
|
|
|
|
# 시간감쇠 기준: published_at (없으면 analyzed_at). 일부 RSS가 오래된 기사 재노출 →
|
|
|
|
|
|
# analyzed_at만 보면 11개월 전 기사도 풀가중 → 점수 왜곡. published_at 정렬·필터링 필수.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
rows = await conn.fetch("""
|
2026-05-20 22:54:17 +09:00
|
|
|
|
SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst,
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
FROM news_analysis
|
2026-05-25 00:48:39 +09:00
|
|
|
|
WHERE (primary_stock=$1 OR stock_impacts ? $1)
|
|
|
|
|
|
AND COALESCE(published_at, analyzed_at) >= $2
|
2026-05-20 22:54:17 +09:00
|
|
|
|
""", stock_code, week_ago_dt)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if not rows:
|
2026-05-20 22:54:17 +09:00
|
|
|
|
return 0.0, {"pos": 0, "neg": 0, "neutral": 0, "total": 0}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
score = 0.0
|
2026-05-20 22:54:17 +09:00
|
|
|
|
pos = neg = neutral = 0
|
2026-05-20 21:33:56 +09:00
|
|
|
|
for r in rows:
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
intensity = float(r["intensity"] or 1)
|
2026-05-20 22:54:17 +09:00
|
|
|
|
try:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
age_days = (now - r["ref_at"]).total_seconds() / 86400.0
|
2026-05-20 22:54:17 +09:00
|
|
|
|
except Exception:
|
|
|
|
|
|
age_days = 3.0
|
|
|
|
|
|
tw = _time_weight(max(0.0, age_days))
|
|
|
|
|
|
sw = _similar_weight(r["sim"])
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 출처 신뢰도: 학습된 값 우선, 없으면 컬럼값 → 평균 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
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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("""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
SELECT sentiment, intensity,
|
|
|
|
|
|
COALESCE(published_at, analyzed_at) AS ref_at,
|
|
|
|
|
|
COALESCE(similar_count, 1) AS sim,
|
2026-05-20 22:54:17 +09:00
|
|
|
|
COALESCE(catalyst, '기타') AS catalyst
|
|
|
|
|
|
FROM news_analysis
|
2026-06-02 01:22:23 +09:00
|
|
|
|
WHERE primary_stock=$1
|
|
|
|
|
|
AND COALESCE(published_at, analyzed_at) >= $2
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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
|
2026-06-02 01:22:23 +09:00
|
|
|
|
if r["ref_at"] >= cutoff_recent:
|
2026-05-20 22:54:17 +09:00
|
|
|
|
recent += val
|
2026-05-20 21:33:56 +09:00
|
|
|
|
else:
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# ── 사후 검증 피드백 루프 ─────────────────────────────────
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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: 시장 레짐 ─────────────────────────────────────────
|
2026-05-25 00:48:39 +09:00
|
|
|
|
async def calc_market_regime(conn, as_of: date | None = None) -> tuple[str, float]:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
KOSPI 종가 vs 200일 이평으로 시장 레짐 판단
|
|
|
|
|
|
위면 강세(+5), 아래면 약세(-10), 데이터 없으면 중립
|
2026-05-25 00:48:39 +09:00
|
|
|
|
as_of=date이면 그 시점 기준 (백필용).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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, 모멘텀 산출 직후)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
if market_cap > 0 and market_cap < 30_000_000_000: # 300억 미만 (잡주 제외)
|
|
|
|
|
|
return False, "시총 300억 미만 (잡주)"
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return True, ""
|
|
|
|
|
|
|
|
|
|
|
|
# ── 일간 점수 산출 ────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)""")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# ══════════════════════════════════════════════════════════
|
|
|
|
|
|
# 신규 보조 시그널 (임원매매 / 컨센서스 / 매크로 / 기관 / 밸류 percentile)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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 {}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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,
|
2026-06-03 22:45:15 +09:00
|
|
|
|
SUM(individual_net) AS ind5d,
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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,
|
2026-06-03 22:45:15 +09:00
|
|
|
|
SUM(individual_net) AS ind5d,
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {r["stock_code"]: dict(r) for r in rows}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calc_inst_flow_signal(flow: dict) -> tuple[float, str]:
|
2026-06-03 22:45:15 +09:00
|
|
|
|
"""기관+개인 수급 시그널 (±15). 기관 순매수 가산(외국인 동행 시 가중),
|
|
|
|
|
|
개인은 컨트래리언: 개인 과열매수=감점, 개인이탈+큰손매집=가점."""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if not flow:
|
|
|
|
|
|
return 0.0, ""
|
|
|
|
|
|
inst5 = int(flow.get("inst5d") or 0)
|
|
|
|
|
|
for5 = int(flow.get("for5d") or 0)
|
2026-06-03 22:45:15 +09:00
|
|
|
|
ind5 = int(flow.get("ind5d") or 0)
|
|
|
|
|
|
if inst5 == 0 and for5 == 0 and ind5 == 0:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return 0.0, ""
|
|
|
|
|
|
import math
|
2026-06-03 22:45:15 +09:00
|
|
|
|
# 기관 시그널: tanh 스케일 (포화) + 외국인 동행 가중
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
2026-06-03 22:45:15 +09:00
|
|
|
|
# 개인 컨트래리언: 순매수→감점(과열), 순매도→가점 (소폭)
|
|
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 12:29:02 +09:00
|
|
|
|
async def calculate_daily_scores(as_of: date | None = None, notify: bool = False):
|
2026-05-25 00:48:39 +09:00
|
|
|
|
"""일간 점수 계산. as_of=None이면 today (운영 모드), as_of=date이면 그 시점 기준 (백필 모드).
|
2026-06-03 12:29:02 +09:00
|
|
|
|
notify=True일 때만 텔레그램 일간리포트/신규강력매수 발신 (16:30 정기 1회만 True).
|
|
|
|
|
|
그 외 호출(통합 워크플로우 30분마다·매시간 스코어·18:30 등)은 silent로 점수만 갱신.
|
2026-05-25 00:48:39 +09:00
|
|
|
|
백필 모드는 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()
|
2026-05-20 21:33:56 +09:00
|
|
|
|
week_ago = today - timedelta(days=7)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
logger.info("scoring.start", as_of=str(today), backfill=backfill_mode)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
strong_buy: list = []
|
|
|
|
|
|
strong_sell: list = []
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# H3: KOSPI 일봉 갱신 후 시장 레짐 계산 — 백필 모드는 이미 있는 데이터 사용, 갱신 스킵
|
|
|
|
|
|
if not backfill_mode:
|
|
|
|
|
|
await fetch_kospi_ohlcv()
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
formula_weights = {f: 1.0 for f in ENSEMBLE_FORMULAS}
|
|
|
|
|
|
formula_weights["graph"] = 1.0
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 사후 학습된 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))
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# 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))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# H3: 시장 레짐 1회 계산 (전 종목 동일 적용)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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:
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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 서비스가 채움)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 주주환원율 사전 로드: (배당금 + 자사주매입) / 시가총액 × 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))
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# 시그널 날짜는 미국장 마감 기준이라 한국 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)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 백필 모드는 Redis·stock_prices 스킵하고 stock_ohlcv에서 그 시점 종가만 사용
|
2026-05-20 21:33:56 +09:00
|
|
|
|
price_score = 0.0
|
|
|
|
|
|
price_change = 0.0
|
|
|
|
|
|
has_price = False
|
|
|
|
|
|
per = pbr = market_cap = 0.0
|
2026-05-25 00:48:39 +09:00
|
|
|
|
if redis_cl and not backfill_mode:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# DB fallback 1: stock_prices (장중 수집 데이터) — 백필 모드 스킵 (30일 보존만)
|
|
|
|
|
|
if not has_price and not backfill_mode:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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 만료 시)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 백필 모드: as_of 이전 종가만 사용. PER/PBR/시총은 0 (그 시점 데이터 없음).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if not has_price:
|
|
|
|
|
|
try:
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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 불일치 방지)
|
2026-06-03 22:12:11 +09:00
|
|
|
|
technical_score = 0.0; rsi_val = None
|
2026-05-20 21:33:56 +09:00
|
|
|
|
try:
|
|
|
|
|
|
ta_row = await conn.fetchrow(
|
2026-06-03 22:12:11 +09:00
|
|
|
|
"SELECT tech_score, rsi FROM stock_technical WHERE stock_code=$1", stock)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if ta_row:
|
|
|
|
|
|
technical_score = float(ta_row["tech_score"] or 0)
|
2026-06-03 22:12:11 +09:00
|
|
|
|
rsi_val = float(ta_row["rsi"]) if ta_row["rsi"] is not None else None
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
2026-06-03 22:12:11 +09:00
|
|
|
|
short_score, short_reason = calc_short_score(s_data, rsi=rsi_val)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
short_weight_val = s_data[0].get("trade_weight", 0) if s_data else 0
|
|
|
|
|
|
except: pass
|
|
|
|
|
|
|
|
|
|
|
|
# 펀더멘털 점수 (dart_financials - 최신 사업보고서 기준)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 백필 모드: 그 시점 이전 공시된 보고서만 사용 (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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)을 써야 영업이익/총자산 단위가 일치
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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년 추세 점수
|
2026-05-25 00:48:39 +09:00
|
|
|
|
trend_score, trend_reason = await calc_trend_score(conn, stock, as_of=today if backfill_mode else None)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
mom_val, mom_sig, mom_reason = await calc_momentum(conn, stock, as_of=today if backfill_mode else None)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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) — 소형 알파
|
2026-05-25 00:48:39 +09:00
|
|
|
|
amihud_val, amihud_sig, amihud_reason = await calc_amihud(conn, stock, as_of=today if backfill_mode else None)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if amihud_reason:
|
|
|
|
|
|
fin_reasons.append(amihud_reason)
|
|
|
|
|
|
|
|
|
|
|
|
# 시장 베타 (BAB — Frazzini-Pedersen 2014)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
beta_val, beta_sig, beta_reason = await calc_beta(conn, stock, as_of=today if backfill_mode else None)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-06-02 01:22:23 +09:00
|
|
|
|
ensemble_summary, vote_counts = aggregate_signals(sig_map, weights=formula_weights)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if ensemble_summary:
|
|
|
|
|
|
fin_reasons.append(f"공식보팅 [{ensemble_summary}]")
|
|
|
|
|
|
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# M4 + A/C: catalyst 가중 + 시간감쇠 + similar_count 적용된 뉴스 점수
|
2026-05-20 21:33:56 +09:00
|
|
|
|
news_score_w, news_stats = await calc_news_score_weighted(conn, stock, week_ago)
|
|
|
|
|
|
news_score = news_score_w
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# 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))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
# 펀더멘털 통합: 기존 + 추세 + 이익품질 + 매직포뮬러 + F-Score (DCF는 종합점수에 별도 가중)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 주주환원율 보너스 (배당+자사주매입 / 시총) — 퀄리티 보완
|
|
|
|
|
|
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}%")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
fundamental_combined = max(-100.0, min(100.0,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
fundamental_score + trend_score + eq_score + magic_score
|
|
|
|
|
|
+ f_score_adj + sy_bonus))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
# 종합 점수 (가중치 재배분)
|
|
|
|
|
|
# 펀더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)
|
2026-05-20 22:54:17 +09:00
|
|
|
|
# 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}")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# 앙상블 보팅 가산점: 학습 가중치 적용 (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
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 사이클 고점 가드: 경기민감주가 급등(고모멘텀) + 저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개 보조 + 사이클 가드
|
2026-05-20 21:33:56 +09:00
|
|
|
|
total = max(-100, min(100,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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}%)")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
# 보조 시그널 근거 (점수에 영향 큰 것만)
|
|
|
|
|
|
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,
|
2026-05-20 22:54:17 +09:00
|
|
|
|
gpa_pct, g_score, amihud_illiq, market_beta,
|
|
|
|
|
|
sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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,
|
2026-05-20 22:54:17 +09:00
|
|
|
|
$35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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,
|
2026-05-20 22:54:17 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
""", stock, name, today,
|
2026-05-20 22:54:17 +09:00
|
|
|
|
news_stats["pos"], news_stats["neg"], news_stats.get("neutral", 0), news_stats["total"],
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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["매도"],
|
2026-05-20 22:54:17 +09:00
|
|
|
|
gpa_val, g_val, amihud_val, beta_val,
|
|
|
|
|
|
sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
# 미국증시 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
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 백필 모드: 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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
ON CONFLICT (stock_code, rec_date) DO NOTHING
|
2026-06-02 01:22:23 +09:00
|
|
|
|
""", r["stock_code"], r["stock_name"], r["recommendation"], r["total_score"], entry_price, today)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 신규 강력매수 즉시 알림 (어제는 강력매수 아니었는데 오늘 새로 등장한 종목)
|
2026-06-03 12:29:02 +09:00
|
|
|
|
if notify and not backfill_mode and strong_buy:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-06-03 12:29:02 +09:00
|
|
|
|
# 텔레그램 알림 (notify=True 정기 1회만 — 매 호출 발신 방지)
|
|
|
|
|
|
if notify and (strong_buy or strong_sell):
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
# ── 정기 브리핑 ───────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-03 16:41:26 +09:00
|
|
|
|
_last_briefing_sent: datetime | None = None # 중복/폭주 차단 쓰로틀
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async def send_briefing():
|
2026-06-03 16:41:26 +09:00
|
|
|
|
"""정기 시황 브리핑 — 종목당 1카드(매매가/포지션/재무/근거 통합).
|
|
|
|
|
|
30분 내 재호출은 무시 — n8n 중복/재시도 폭주로 같은 브리핑이 여러 건 발송되는 것 방지."""
|
|
|
|
|
|
global _last_briefing_sent
|
2026-05-20 21:33:56 +09:00
|
|
|
|
now = datetime.now()
|
2026-06-03 16:41:26 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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=["*"])
|
|
|
|
|
|
|
2026-06-03 16:41:26 +09:00
|
|
|
|
async def _daily_score_notify():
|
|
|
|
|
|
"""16:30 정기 일간리포트 — 코루틴 함수로 등록해야 APScheduler가 await함
|
|
|
|
|
|
(lambda로 감싸면 coroutine never awaited 버그)."""
|
|
|
|
|
|
await calculate_daily_scores(notify=True)
|
|
|
|
|
|
|
2026-06-03 19:47:17 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@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()
|
2026-06-03 16:41:26 +09:00
|
|
|
|
scheduler.add_job(_daily_score_notify, "cron",
|
2026-05-20 21:33:56 +09:00
|
|
|
|
day_of_week="mon-fri", hour=16, minute=30,
|
|
|
|
|
|
id="daily_score", replace_existing=True)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 텔레그램 정기 알림 하루 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 수동 엔드포인트는 유지 — 자동 발신만 중단)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# 데이터 정리: 매일 새벽 4시
|
|
|
|
|
|
scheduler.add_job(cleanup_old_data, "cron",
|
|
|
|
|
|
hour=4, minute=0,
|
|
|
|
|
|
id="cleanup", replace_existing=True)
|
2026-06-03 12:29:02 +09:00
|
|
|
|
# 데이터 무결성 모니터: 평일 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)
|
2026-06-03 12:32:16 +09:00
|
|
|
|
# 핫종목 검증팀: 평일 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)
|
2026-06-03 12:34:43 +09:00
|
|
|
|
# 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)
|
2026-06-03 12:42:12 +09:00
|
|
|
|
# 결정안 성과 채점: 매일 18:10 (성과가격 갱신 18:00 이후)
|
|
|
|
|
|
scheduler.add_job(verify_decisions_job, "cron",
|
|
|
|
|
|
hour=18, minute=10,
|
|
|
|
|
|
id="verify_decisions", replace_existing=True)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# 성과 추적: 매일 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) — 데이터 누적되면 자동 활성화
|
2026-06-03 19:47:17 +09:00
|
|
|
|
scheduler.add_job(_learn_weights_job, "cron",
|
2026-05-20 21:33:56 +09:00
|
|
|
|
day_of_week="sun", hour=4, minute=0,
|
|
|
|
|
|
id="learn_weights", replace_existing=True)
|
2026-06-03 19:47:17 +09:00
|
|
|
|
scheduler.add_job(_learn_pricing_job, "cron",
|
2026-05-20 21:33:56 +09:00
|
|
|
|
day_of_week="sun", hour=5, minute=0,
|
|
|
|
|
|
id="learn_pricing", replace_existing=True)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
scheduler.start()
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 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())
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# pricing_model_v2: 120일 이상 된 모델 삭제 (model_blob 누적 방지, 최신만 사용)
|
|
|
|
|
|
await conn.execute(
|
|
|
|
|
|
"DELETE FROM pricing_model_v2 WHERE model_date < CURRENT_DATE - 120")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
logger.info("cleanup.done",
|
|
|
|
|
|
prices=deleted_prices, recs=deleted_recs,
|
|
|
|
|
|
news=deleted_news, signals=deleted_signals)
|
|
|
|
|
|
|
2026-06-03 12:29:02 +09:00
|
|
|
|
# ── 데이터 무결성 모니터 ("데이터 제대로 쌓이는지" 상시 감시) ──────────
|
|
|
|
|
|
_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}")
|
2026-06-04 00:27:31 +09:00
|
|
|
|
|
|
|
|
|
|
# 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}행")
|
2026-06-03 12:29:02 +09:00
|
|
|
|
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 수익률·알파) 집계.
|
2026-06-04 00:27:31 +09:00
|
|
|
|
동전주 폭등·불량 가격데이터 이상치가 평균(mean)을 왜곡하므로 중앙값(median)으로 집계.
|
2026-06-03 12:29:02 +09:00
|
|
|
|
매수계열 알파>0 & 매도계열 알파<0 이면 방식 유효."""
|
|
|
|
|
|
async with pg_pool.acquire() as conn:
|
|
|
|
|
|
grades = await conn.fetch("""
|
|
|
|
|
|
SELECT recommendation rec, COUNT(*) n,
|
2026-06-04 00:27:31 +09:00
|
|
|
|
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,
|
2026-06-03 12:29:02 +09:00
|
|
|
|
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)
|
2026-06-04 00:27:31 +09:00
|
|
|
|
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)
|
2026-06-03 12:29:02 +09:00
|
|
|
|
order = {"강력매수": 0, "매수관심": 1, "관망": 2, "매도관심": 3, "강력매도": 4}
|
|
|
|
|
|
rows = sorted([dict(g) for g in grades], key=lambda x: order.get(x["rec"], 9))
|
2026-06-04 00:27:31 +09:00
|
|
|
|
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일 변별력 부족)"
|
2026-06-03 12:29:02 +09:00
|
|
|
|
return {
|
|
|
|
|
|
"days": days,
|
2026-06-04 00:27:31 +09:00
|
|
|
|
"agg": "median",
|
|
|
|
|
|
"basis": "30d",
|
2026-06-03 12:29:02 +09:00
|
|
|
|
"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,
|
2026-06-04 00:27:31 +09:00
|
|
|
|
"buy_alpha30": buy_a30, "sell_alpha30": sell_a30, "spread30": spread30,
|
|
|
|
|
|
"verdict": verdict,
|
2026-06-03 12:29:02 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-06-04 00:27:31 +09:00
|
|
|
|
lines = ["📈 <b>추천 정확도 리포트 (최근90일·30일 알파·중앙값)</b>",
|
2026-06-03 12:29:02 +09:00
|
|
|
|
f"판정: <b>{a['verdict']}</b>",
|
2026-06-04 00:27:31 +09:00
|
|
|
|
f"매수계열 알파 {a['buy_alpha30']} / 매도계열 알파 {a['sell_alpha30']} / 강력매수−강력매도 스프레드 {a['spread30']}%p\n"]
|
2026-06-03 12:29:02 +09:00
|
|
|
|
for g in a["grades"]:
|
2026-06-04 00:27:31 +09:00
|
|
|
|
lines.append(f"{g['rec']}: n{g['n']} 30일수익{g['ret30']}% 알파{g['alpha30']}% (7일알파{g['alpha7']}%)")
|
2026-06-03 12:29:02 +09:00
|
|
|
|
await send_telegram("\n".join(lines))
|
|
|
|
|
|
logger.info("accuracy.report.sent", verdict=a["verdict"])
|
|
|
|
|
|
|
2026-06-03 12:32:16 +09:00
|
|
|
|
# ── 🔥 핫종목 검증팀 (키움 핫 × 가치/품질 노이즈필터, 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"])
|
|
|
|
|
|
|
2026-06-03 12:34:43 +09:00
|
|
|
|
# ── 🧭 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:
|
2026-06-03 12:42:12 +09:00
|
|
|
|
"""점수+보팅+리스크+핫검증 종합 → 오늘의 매수/매도 결정안 (dry-run, 승인 전).
|
|
|
|
|
|
매수=추천 상위 후보, 매도=보유종목(user_portfolio) 등급악화·손절. entry_price 기록(사후추적)."""
|
2026-06-03 12:34:43 +09:00
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
""")
|
2026-06-03 12:42:12 +09:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-06-03 12:34:43 +09:00
|
|
|
|
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()
|
2026-06-03 12:42:12 +09:00
|
|
|
|
|
|
|
|
|
|
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 = []
|
|
|
|
|
|
# ── 매수 결정안 (추천 상위 후보) ──
|
2026-06-03 12:34:43 +09:00
|
|
|
|
cands = await conn.fetch("""
|
|
|
|
|
|
SELECT s.stock_code, COALESCE(d.corp_name, s.stock_code) name,
|
|
|
|
|
|
s.total_score, s.recommendation, s.buy_votes,
|
2026-06-03 12:42:12 +09:00
|
|
|
|
s.position_size_pct, s.top_reasons, s.beneish_score, s.earnings_quality
|
2026-06-03 12:34:43 +09:00
|
|
|
|
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("핫종목 검증통과")
|
2026-06-03 12:42:12 +09:00
|
|
|
|
ep = await _cur_price(c["stock_code"]) or 0
|
2026-06-03 12:34:43 +09:00
|
|
|
|
await conn.execute("""
|
|
|
|
|
|
INSERT INTO daily_decisions
|
2026-06-03 12:42:12 +09:00
|
|
|
|
(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
|
2026-06-03 12:34:43 +09:00
|
|
|
|
""", today, c["stock_code"], c["name"], conv, size,
|
2026-06-03 12:42:12 +09:00
|
|
|
|
c["total_score"], (c["top_reasons"] or "")[:300], " · ".join(risk), int(ep))
|
2026-06-03 12:34:43 +09:00
|
|
|
|
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,
|
2026-06-03 12:42:12 +09:00
|
|
|
|
"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}
|
2026-06-03 12:34:43 +09:00
|
|
|
|
|
|
|
|
|
|
@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]
|
|
|
|
|
|
|
2026-06-03 12:42:12 +09:00
|
|
|
|
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]}
|
|
|
|
|
|
|
2026-06-03 12:34:43 +09:00
|
|
|
|
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"])
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@app.get("/health")
|
|
|
|
|
|
async def health():
|
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/score/calculate")
|
2026-06-03 12:29:02 +09:00
|
|
|
|
async def manual_calc(notify: bool = Query(default=False)):
|
|
|
|
|
|
n = await calculate_daily_scores(notify=notify)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {"status": "done", "scored": n}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@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]
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
@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 "중립 — 미세 우위"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 한국주식 왕복 거래비용 추정(증권거래세+수수료+슬리피지). 백테스트 순수익률 보정용.
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@app.get("/backtest")
|
|
|
|
|
|
async def backtest(days: int = Query(default=180, ge=30, le=365)):
|
|
|
|
|
|
"""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
M1: 과거 추천 종목의 7d/30d 수익률, KOSPI 대비 알파, 적중률, 샤프, MDD 산출.
|
|
|
|
|
|
수익률·알파는 왕복 거래비용(ROUND_TRIP_COST_PCT) 차감한 순(net) 기준.
|
|
|
|
|
|
MDD는 rec_date별 등비중 포트폴리오 자산곡선의 최대낙폭(동일자 추천은 병렬 보유).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
2026-06-02 01:22:23 +09:00
|
|
|
|
ORDER BY rec_date
|
2026-05-20 21:33:56 +09:00
|
|
|
|
""", since)
|
|
|
|
|
|
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return {"period_days": days, "n": 0, "msg": "데이터 없음"}
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {"n": 0}
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
# 일간 변동성 가정 안 하고 단순 샤프 근사 (mean/sd, RFR=0)
|
|
|
|
|
|
sharpe = avg_ret / sd if sd > 0 else 0
|
2026-06-02 01:22:23 +09:00
|
|
|
|
avg_alpha = sum(net_alphas) / len(net_alphas) if net_alphas else None
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {
|
|
|
|
|
|
"n": n,
|
|
|
|
|
|
"avg_return_pct": round(avg_ret, 2),
|
|
|
|
|
|
"win_rate_pct": round(win, 1),
|
|
|
|
|
|
"stdev": round(sd, 2),
|
|
|
|
|
|
"sharpe": round(sharpe, 2),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"max_drawdown_pct": mdd,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"avg_alpha_pct": round(avg_alpha, 2) if avg_alpha is not None else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
overall = {}
|
|
|
|
|
|
for window in ("7d", "30d"):
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
by_rec = {}
|
|
|
|
|
|
for rec in ("강력매수", "매수관심"):
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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)})
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"period_days": days,
|
|
|
|
|
|
"total_recommendations": len(rows),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"round_trip_cost_pct": cost,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"overall": overall,
|
|
|
|
|
|
"by_recommendation_7d": by_rec,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"equity_curve": equity_curve,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
@app.post("/performance/recompute")
|
|
|
|
|
|
async def m_recompute_performance():
|
|
|
|
|
|
"""기존 recommendation_performance 행을 OHLCV 종가로 재계산 (라벨 정정).
|
|
|
|
|
|
OHLCV 부재(보존기간 초과) 행은 skipped로 집계하고 기존 값 유지."""
|
|
|
|
|
|
return await update_performance_prices(force=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@app.post("/learn-weights")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
async def learn_weights(days: int = Query(default=90, ge=14, le=365),
|
|
|
|
|
|
segment: str = Query(default="all")):
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
백테스트 기반 공식별 가중치 학습.
|
|
|
|
|
|
각 공식이 '매수' 신호를 낸 종목들의 평균 7일 수익률 - '매도' 신호 종목 평균 = edge
|
2026-06-02 01:22:23 +09:00
|
|
|
|
edge가 큰 공식일수록 가중치 ↑ → ensemble 보팅에 반영.
|
|
|
|
|
|
|
|
|
|
|
|
segment: "all" | "regime:강세|중립|약세" | "sector:반도체|2차전지|..."
|
|
|
|
|
|
표본 크기로 edge 신뢰도 조정 (n<10이면 edge=0 처리).
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
|
|
|
|
|
since = date.today() - timedelta(days=days)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
formulas = ENSEMBLE_FORMULAS
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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"""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
2026-06-02 01:22:23 +09:00
|
|
|
|
WHERE {where}
|
|
|
|
|
|
""", *params)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
if not rows:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
return {"period_days": days, "segment": segment, "sample": 0,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"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"]))
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
out[f] = {
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"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),
|
2026-05-20 21:33:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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("""
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
return {"period_days": days, "segment": segment, "sample": len(rows),
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"by_formula": out, "weights": weights,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"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],
|
|
|
|
|
|
}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# ── 학습 보강: 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 13:41:52 +09:00
|
|
|
|
def _walk_forward_folds(rows, n_folds: int, embargo_days: int = 0):
|
2026-05-25 00:48:39 +09:00
|
|
|
|
"""
|
2026-06-06 13:41:52 +09:00
|
|
|
|
시간순 정렬된 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) 차단.
|
2026-05-25 00:48:39 +09:00
|
|
|
|
"""
|
|
|
|
|
|
n = len(rows)
|
|
|
|
|
|
if n < (n_folds + 1) * 3:
|
|
|
|
|
|
return []
|
2026-06-06 13:41:52 +09:00
|
|
|
|
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)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
folds = []
|
|
|
|
|
|
for i in range(1, n_folds + 1):
|
2026-06-06 13:41:52 +09:00
|
|
|
|
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
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@app.post("/learn-pricing")
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)):
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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.
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
since = date.today() - timedelta(days=days)
|
|
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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"— 추천·성과 누적 후 재학습")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
import numpy as np
|
2026-05-25 00:48:39 +09:00
|
|
|
|
from sklearn.linear_model import Ridge
|
2026-05-20 21:33:56 +09:00
|
|
|
|
from sklearn.ensemble import RandomForestRegressor
|
2026-06-06 13:41:52 +09:00
|
|
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
|
|
from sklearn.pipeline import make_pipeline
|
2026-05-20 21:33:56 +09:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {**out, "err": f"sklearn import 실패: {e}"}
|
|
|
|
|
|
|
2026-06-06 13:41:52 +09:00
|
|
|
|
# 라벨 수익 윈도 길이만큼 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)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
if not folds:
|
|
|
|
|
|
return {**out, "err": "fold 구성 실패 (표본 부족)"}
|
|
|
|
|
|
|
2026-06-06 13:41:52 +09:00
|
|
|
|
# ── 1. Linear (Ridge + 표준화) ─────────────────────────
|
|
|
|
|
|
# 피처 스케일이 -100~수천(점수 vs Amihud illiq vs log_mcap)으로 천차만별이라
|
|
|
|
|
|
# Ridge L2 패널티가 스케일에 휘둘려 계수가 왜곡됨 → 폴드마다 train에만 fit한
|
|
|
|
|
|
# StandardScaler를 파이프라인으로 결합. (scaler는 train 통계만 사용 → 누수 없음)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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])
|
2026-06-06 13:41:52 +09:00
|
|
|
|
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)))
|
2026-05-25 00:48:39 +09:00
|
|
|
|
|
|
|
|
|
|
linear_metrics = _aggregate_fold_metrics(fold_metrics_lin)
|
|
|
|
|
|
|
2026-06-06 13:41:52 +09:00
|
|
|
|
# 전체 데이터 재학습 (배포용 모델) — 표준화 공간에서 학습 후 계수를 원본 피처 공간으로
|
|
|
|
|
|
# 역변환해 저장. /predict-price가 원본 피처로 intercept+Σcoef·x를 그대로 계산하므로
|
|
|
|
|
|
# 스케일러를 따로 들고 다닐 필요 없이 동일 예측이 보장됨.
|
2026-05-25 00:48:39 +09:00
|
|
|
|
X_all = np.array([_row_features(r) for r in rows])
|
|
|
|
|
|
y_all = np.array([float(r[target]) for r in rows])
|
2026-06-06 13:41:52 +09:00
|
|
|
|
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)}
|
2026-05-25 00:48:39 +09:00
|
|
|
|
|
|
|
|
|
|
# ── 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),
|
2026-06-06 13:41:52 +09:00
|
|
|
|
lin_intercept,
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
await conn.execute("""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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 {}))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
await conn.execute("""
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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 일치 모델 우선)"}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/predict-price/{code}")
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
|
|
|
|
|
"""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-05-25 00:48:39 +09:00
|
|
|
|
s = await conn.fetchrow(f"""
|
|
|
|
|
|
SELECT {", ".join(LEARN_FEATURE_NAMES)}, sector
|
2026-05-20 21:33:56 +09:00
|
|
|
|
FROM stock_scores WHERE stock_code=$1
|
|
|
|
|
|
ORDER BY score_date DESC LIMIT 1
|
|
|
|
|
|
""", code)
|
2026-05-25 00:48:39 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
if not m:
|
|
|
|
|
|
return {"code": code, "msg": "학습 모델 없음 — 먼저 /learn-pricing 호출"}
|
|
|
|
|
|
|
2026-05-25 00:48:39 +09:00
|
|
|
|
# 예측: 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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {
|
|
|
|
|
|
"code": code,
|
|
|
|
|
|
"current_price": cur_price,
|
2026-05-25 00:48:39 +09:00
|
|
|
|
"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,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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}")
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 단기 가격 추세 (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
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return "\n".join(ctx), meta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_DEEP_SYSTEM = (
|
|
|
|
|
|
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
|
|
|
|
|
|
"제공된 정량 데이터(퀀트 종합점수·학술공식 신호·재무추세·기술적·뉴스흐름)를 "
|
|
|
|
|
|
"종합해 매수/매도를 판단합니다.\n"
|
|
|
|
|
|
"판단 우선순위: 기업 본질가치(ROE·영업이익률·FCF·부채안정성·이익품질) > "
|
|
|
|
|
|
"밸류에이션(PER·PBR·DCF안전마진) > 뉴스 catalyst·모멘텀 > 단기 수급.\n"
|
|
|
|
|
|
"주어진 데이터에 근거해서만 판단하고 데이터에 없는 사실을 지어내지 마세요.\n"
|
|
|
|
|
|
"퀀트 시스템판정과 다른 결론을 내릴 경우 그 이유를 thesis에 명확히 쓰세요.\n"
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"민감 신호 가드: "
|
|
|
|
|
|
"(1) 단기 추세 5일 ≤ -5% 또는 20일 ≤ -10%면 '떨어지는 칼날' 위험 — 펀더 강해도 '매수' 보류 권고. "
|
|
|
|
|
|
"(2) 최근 14일 ⚠️정정 공시가 있고 시장 신뢰 영향이 의심되면 risks에 반드시 기재. "
|
|
|
|
|
|
"(3) 공매도 잔고 증가 + 거래비중 10% 초과는 매도 압력 강함 → 매수 등급 하향. "
|
|
|
|
|
|
"(4) PER > 80은 'valuation_view: 고평가'로 명시. "
|
|
|
|
|
|
"(5) 외국인 5일 누적 매도 우세면 risks에 수급 악화 명시.\n"
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"반드시 아래 스키마의 유효한 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"]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 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("{"):
|
2026-05-20 21:33:56 +09:00
|
|
|
|
s, e = c.find("{"), c.rfind("}")
|
|
|
|
|
|
if s != -1 and e > s:
|
|
|
|
|
|
c = c[s:e + 1]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
c = c.translate({i: " " for i in range(0x20) if i not in (0x09, 0x0a, 0x0d)})
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return json.loads(c)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
except Exception:
|
2026-05-20 21:33:56 +09:00
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
def _norm_int(v) -> int:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(float(v))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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]
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
return {
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"recommendation": rec, "conviction": conv,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"thesis": str(a.get("thesis", "") or "")[:1000],
|
2026-05-20 21:33:56 +09:00
|
|
|
|
"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,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"parsed_ok": bool(a),
|
2026-05-20 21:33:56 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-20 21:33:56 +09:00
|
|
|
|
await conn.execute("""
|
|
|
|
|
|
INSERT INTO deep_analysis (
|
|
|
|
|
|
stock_code, stock_name, analysis_date, recommendation, conviction,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
created_at=NOW()
|
2026-06-02 01:22:23 +09:00
|
|
|
|
""", 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))
|
2026-05-20 21:33:56 +09:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"code": code, "name": meta["name"],
|
|
|
|
|
|
"quant_score": meta["quant_score"], "quant_rec": meta["quant_rec"],
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"agreement": agreement, "llm_cost_krw": cost_krw,
|
2026-05-20 21:33:56 +09:00
|
|
|
|
**report,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_deep(r: dict) -> str:
|
|
|
|
|
|
if r.get("error"):
|
|
|
|
|
|
return f"⚠️ {r['code']}: {r['error']}"
|
|
|
|
|
|
icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "⚪",
|
|
|
|
|
|
"매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "⚪")
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 하이브리드 검증 표시 (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 ""
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
lines = [
|
2026-06-02 01:22:23 +09:00
|
|
|
|
f"{icon} <b>{r['name']}({r['code']})</b> — AI심층분석{agr_tag}",
|
2026-05-20 21:33:56 +09:00
|
|
|
|
f"판단: <b>{r['recommendation']}</b> (확신도 {r['conviction']}/5) "
|
2026-06-02 01:22:23 +09:00
|
|
|
|
f"· 퀀트 {r['quant_score']:.0f}점[{r['quant_rec']}]{cost_tag}",
|
2026-05-20 21:33:56 +09:00
|
|
|
|
f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}",
|
|
|
|
|
|
"",
|
|
|
|
|
|
f"<b>📝 투자논거</b>\n{r['thesis']}",
|
|
|
|
|
|
]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 두 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]}")
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
include_context: bool = Query(default=False),
|
|
|
|
|
|
model: str = Query(default="hybrid",
|
|
|
|
|
|
regex="^(exaone|gemini|hybrid)$")):
|
|
|
|
|
|
"""RAG + LLM 종목 심층분석. model=hybrid(기본): EXAONE+Gemini 동시 호출하여 비교."""
|
2026-05-20 21:33:56 +09:00
|
|
|
|
async with pg_pool.acquire() as conn:
|
|
|
|
|
|
if not refresh:
|
|
|
|
|
|
cached = await conn.fetchrow("""
|
|
|
|
|
|
SELECT stock_name, recommendation, conviction, target_price,
|
2026-06-02 01:22:23 +09:00
|
|
|
|
stop_loss, thesis, report, quant_score, rag_context,
|
|
|
|
|
|
agreement, llm_cost_krw
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"quant_rec": "-", "cached": True,
|
|
|
|
|
|
"agreement": cached["agreement"],
|
|
|
|
|
|
"llm_cost_krw": cached["llm_cost_krw"] or 0,
|
|
|
|
|
|
**rep}
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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:
|
2026-06-02 01:22:23 +09:00
|
|
|
|
res = await run_deep_analysis(conn, client, code, save=True, model=model)
|
2026-05-20 21:33:56 +09:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
@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"]),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:33:56 +09:00
|
|
|
|
@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))
|
2026-06-02 01:22:23 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 토글
|
2026-06-03 13:16:30 +09:00
|
|
|
|
"auto_execute": True, # 제안 즉시 자동 체결(모의). False면 텔레그램 버튼 승인 대기
|
2026-06-02 01:22:23 +09:00
|
|
|
|
"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
|
2026-06-06 15:45:46 +09:00
|
|
|
|
# 자본금: .env TRADE_DEFAULT_CAPITAL로 설정(미설정 시 1천만). 손실%·포지션사이즈 기준값.
|
|
|
|
|
|
# 런타임 변경은 POST /trade/settings?default_capital=... (재시작 시 env 값으로 복귀).
|
|
|
|
|
|
"default_capital": int(os.getenv("TRADE_DEFAULT_CAPITAL", "10000000")),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 매도 신호 임계값
|
|
|
|
|
|
"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,
|
2026-06-06 15:45:46 +09:00
|
|
|
|
capital: int | None = Query(default=None, ge=1_000_000)):
|
|
|
|
|
|
"""수동 매수 신호 평가 + confirm 요청 (자동 cron으로도 호출됨).
|
|
|
|
|
|
capital 미지정 시 설정 자본금(TRADE_SETTINGS['default_capital']) 사용."""
|
|
|
|
|
|
cap = capital or TRADE_SETTINGS["default_capital"]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
async with pg_pool.acquire() as conn:
|
2026-06-06 15:45:46 +09:00
|
|
|
|
return await propose_buy_order(conn, code, cap)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|
2026-06-06 15:45:46 +09:00
|
|
|
|
async def trade_scan(capital: int | None = Query(default=None),
|
2026-06-02 01:22:23 +09:00
|
|
|
|
auto_propose: bool = Query(default=False)):
|
2026-06-06 15:45:46 +09:00
|
|
|
|
"""모든 강력매수 종목 평가 → 통과 종목 표시 (또는 자동 proposal).
|
|
|
|
|
|
capital 미지정 시 설정 자본금(TRADE_SETTINGS['default_capital']) 사용."""
|
|
|
|
|
|
cap = capital or TRADE_SETTINGS["default_capital"]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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"]:
|
2026-06-06 15:45:46 +09:00
|
|
|
|
await propose_buy_order(conn, c, cap)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 13:16:30 +09:00
|
|
|
|
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"]
|
2026-06-02 01:22:23 +09:00
|
|
|
|
await conn.execute(
|
2026-06-03 13:16:30 +09:00
|
|
|
|
"UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
|
2026-06-02 01:22:23 +09:00
|
|
|
|
await conn.execute("""
|
2026-06-03 13:16:30 +09:00
|
|
|
|
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)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
return {"status": "filled", "order_id": order_id}
|
|
|
|
|
|
|
2026-06-03 13:16:30 +09:00
|
|
|
|
@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],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 01:22:23 +09:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-06-06 13:41:52 +09:00
|
|
|
|
if pnl and pnl["halted"]:
|
|
|
|
|
|
return # 이미 중단된 날
|
2026-06-02 01:22:23 +09:00
|
|
|
|
capital = TRADE_SETTINGS["default_capital"]
|
2026-06-06 13:41:52 +09:00
|
|
|
|
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
|
2026-06-02 01:22:23 +09:00
|
|
|
|
# 매도 스캔 먼저 (보유 종목 리스크 관리 우선)
|
|
|
|
|
|
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)
|
2026-06-03 13:16:30 +09:00
|
|
|
|
# 자동실행(모의): 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)
|
2026-06-02 01:22:23 +09:00
|
|
|
|
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,
|
|
|
|
|
|
}
|