From 47e7a5fb668c9ac350d4593c48ff00dca42de22c Mon Sep 17 00:00:00 2001 From: kyu Date: Wed, 20 May 2026 22:54:17 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=90=EC=84=B1=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=EC=84=B1=20=EA=B0=95=ED=99=94=20(A+B+C+D+E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit score-engine/main.py: A) 시간감쇠: exp 3일 반감기 (3d→0.5, 7d→0.2) A) 유사뉴스 중복 보정: sqrt(similar_count) cap 2.5x C) catalyst 패턴 매칭: "영업이익 급증" 등 세분 라벨을 실적/수주/배당/리스크/ M&A/신제품/규제/정책/모멘텀 9개 그룹으로 통합 B) calc_sentiment_momentum: 최근 3일 vs 이전 4일 변화율 (-50~+50) D) calc_market_sentiment_baseline + per-stock sentiment_alpha (-100~+100, 시장 평균 대비 고유 edge) E) calc_news_surge_and_attention: 7d/28d 비율 + log 관심도(0~100) 종합점수 통합: - news_score (0.18)는 A+C로 직접 정확도 향상 - sentiment_momentum * 0.06 + sentiment_alpha * 0.03 → max ±5 보너스 - surge≥3 AND news_score>10 → +2 (강한 호재 attention 가산) stock_scores 스키마: 4컬럼 추가 - sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio dashboard-api: - /api/formulas/matrix: 5컬럼(news_score + 4신규) 추가 노출 - index.html: 10공식 매트릭스에 뉴스/M/α/관심/× 5컬럼 추가 검증: 1464종목 재산출 완료. attention >0 종목 1294(88%), 모멘텀 변동 259종목. 추천 분포 안정 (강력매수 19 / 매수관심 104 / 관망 1249 / 매도관심 73 / 강력매도 22). Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard-api/index.html | 35 +++++++ dashboard-api/main.py | 4 +- score-engine/main.py | 210 ++++++++++++++++++++++++++++++++++----- 3 files changed, 223 insertions(+), 26 deletions(-) diff --git a/dashboard-api/index.html b/dashboard-api/index.html index 76d0001..960f021 100644 --- a/dashboard-api/index.html +++ b/dashboard-api/index.html @@ -1730,6 +1730,11 @@ async function renderFormulas(){ G Amh β + 뉴스 + M + α + 관심 + × `; rows.forEach(r=>{ let sig = r.signals || {}; @@ -1757,6 +1762,36 @@ async function renderFormulas(){ ${_cell('G', r.g_score!=null?(+r.g_score).toFixed(0):'-', hit('gscore'), 'Mohanram G (vs 섹터)')} ${_cell('Amh', r.amihud_illiq!=null?(+r.amihud_illiq).toFixed(0):'-', hit('amihud'), 'Amihud 비유동성')} ${_cell('β', r.market_beta!=null?(+r.market_beta).toFixed(2):'-', hit('beta'), '60일 KOSPI 회귀 베타')} + ${(()=>{ // 뉴스점수 + const v = r.news_score; + if(v==null) return '-'; + const c = v>=10?'#69F0AE':v<=-10?'#FF8A80':'#90A4AE'; + return `${(+v).toFixed(0)}`; + })()} + ${(()=>{ // 모멘텀 + const v = r.sentiment_momentum; + if(v==null) return '-'; + const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE'; + return `${(+v).toFixed(1)}`; + })()} + ${(()=>{ // alpha + const v = r.sentiment_alpha; + if(v==null) return '-'; + const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE'; + return `${v>0?'+':''}${(+v).toFixed(0)}`; + })()} + ${(()=>{ // attention + const v = r.attention_score; + if(v==null) return '-'; + const c = v>=70?'#FFD740':v>=40?'#69F0AE':'#90A4AE'; + return `${(+v).toFixed(0)}`; + })()} + ${(()=>{ // surge + const v = r.news_surge_ratio; + if(v==null) return '-'; + const c = v>=3?'#FFD740':v>=1.5?'#69F0AE':'#546E7A'; + return `${(+v).toFixed(1)}×`; + })()} `; }); h += ``; diff --git a/dashboard-api/main.py b/dashboard-api/main.py index 4034454..5e1bbe2 100644 --- a/dashboard-api/main.py +++ b/dashboard-api/main.py @@ -2017,7 +2017,9 @@ async def formulas_matrix(limit: int = Query(default=50, ge=5, le=200)): s.total_score, s.recommendation, s.buy_votes, s.sell_votes, s.magic_score, s.f_score, s.altman_z, s.peg, s.momentum_pct, s.beneish_score, s.gpa_pct, s.g_score, s.amihud_illiq, s.market_beta, - s.signals, s.sector + s.signals, s.sector, + s.news_score, s.sentiment_momentum, s.sentiment_alpha, + s.attention_score, s.news_surge_ratio FROM stock_scores s LEFT JOIN dart_corps d ON d.stock_code = s.stock_code WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) diff --git a/score-engine/main.py b/score-engine/main.py index fce0996..0d4c613 100644 --- a/score-engine/main.py +++ b/score-engine/main.py @@ -9,7 +9,7 @@ - 매일 장 마감 후 자동 집계 + 텔레그램 알림 """ import asyncio, json, os -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta, timezone from typing import Optional import asyncpg, httpx, redis.asyncio as aioredis, structlog from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -994,33 +994,166 @@ async def calc_position_size(conn, stock_code: str, total_score: float) -> tuple return round(max(1.0, min(15.0, size)), 2), round(vol, 2) -# ── M4: catalyst 가중치 ─────────────────────────────────── +# ── 감성 평가 강화 모듈 (M4 + A/B/C/D/E) ────────────────── +import math as _math + +# catalyst 가중치 (정규화된 라벨 기준) CATALYST_WEIGHTS = { - "실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4, "기타": 1.0, "모멘텀": 0.8, + "실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4, + "M&A": 1.3, "신제품": 1.2, "규제": 1.3, "정책": 1.2, + "기타": 1.0, "모멘텀": 0.8, } -async def calc_news_score_weighted(conn, stock_code: str, week_ago: date) -> tuple[float, dict]: - """catalyst별 가중치 적용된 뉴스 점수""" +# 세분 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)) + + +async def calc_news_score_weighted( + conn, stock_code: str, week_ago: date, now: datetime | None = None +) -> tuple[float, dict]: + """ + catalyst×intensity×시간감쇠×중복가중 적용된 뉴스 점수 (-100~+100). + 동시에 attention/neutral 카운트도 함께 반환 (호재·악재만 점수에 반영). + """ + now = now or datetime.now(timezone.utc) + week_ago_dt = datetime.combine(week_ago, datetime.min.time(), tzinfo=timezone.utc) rows = await conn.fetch(""" - SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst + SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst, + analyzed_at, COALESCE(similar_count, 1) AS sim + FROM news_analysis + WHERE primary_stock=$1 AND analyzed_at >= $2 + """, stock_code, week_ago_dt) + if not rows: + return 0.0, {"pos": 0, "neg": 0, "neutral": 0, "total": 0} + score = 0.0 + pos = neg = neutral = 0 + for r in rows: + sent = r["sentiment"] + if sent == "중립": + neutral += 1 + continue + if sent not in ("호재", "악재"): + continue + cat = _map_catalyst(r["catalyst"]) + cw = CATALYST_WEIGHTS.get(cat, 1.0) + intensity = float(r["intensity"] or 1) + # 시간감쇠 (3일 반감기) + try: + age_days = (now - r["analyzed_at"]).total_seconds() / 86400.0 + except Exception: + age_days = 3.0 + tw = _time_weight(max(0.0, age_days)) + sw = _similar_weight(r["sim"]) + delta = intensity * 5.0 * cw * tw * sw + if sent == "호재": + score += delta; pos += 1 + else: + score -= delta; neg += 1 + return max(-100.0, min(100.0, score)), { + "pos": pos, "neg": neg, "neutral": neutral, "total": len(rows) + } + + +async def calc_sentiment_momentum( + conn, stock_code: str, now: datetime | None = None +) -> float: + """ + 최근 3일 가중 sentiment 합 - 그 이전 4일 가중 sentiment 합 → 모멘텀 (-50~+50). + """ + now = now or datetime.now(timezone.utc) + cutoff_recent = now - timedelta(days=3) + cutoff_old = now - timedelta(days=7) + rows = await conn.fetch(""" + SELECT sentiment, intensity, analyzed_at, COALESCE(similar_count, 1) AS sim, + COALESCE(catalyst, '기타') AS catalyst FROM news_analysis WHERE primary_stock=$1 AND analyzed_at >= $2 AND sentiment IN ('호재','악재') - """, stock_code, datetime.combine(week_ago, datetime.min.time())) - if not rows: - return 0.0, {"pos": 0, "neg": 0, "total": 0} - score = 0.0 - pos = neg = 0 + """, stock_code, cutoff_old) + recent = old = 0.0 for r in rows: - w = CATALYST_WEIGHTS.get(r["catalyst"], 1.0) - intensity = float(r["intensity"] or 1) - if r["sentiment"] == "호재": - score += intensity * 5 * w - pos += 1 + cw = CATALYST_WEIGHTS.get(_map_catalyst(r["catalyst"]), 1.0) + sw = _similar_weight(r["sim"]) + val = float(r["intensity"] or 1) * cw * sw + if r["sentiment"] == "악재": val = -val + if r["analyzed_at"] >= cutoff_recent: + recent += val else: - score -= intensity * 5 * w - neg += 1 - return max(-100.0, min(100.0, score)), {"pos": pos, "neg": neg, "total": len(rows)} + old += val + # 일평균으로 정규화 (3일 vs 4일) 후 차이 → 약간 증폭 + momentum = (recent / 3.0) - (old / 4.0) + return max(-50.0, min(50.0, momentum * 2.0)) + + +async def calc_news_surge_and_attention( + conn, stock_code: str, now: datetime | None = None +) -> tuple[float, float]: + """ + (surge_ratio, attention_score) 반환. + surge_ratio = 최근 7일 일평균 뉴스 / 이전 28일 일평균 뉴스 (>1 = 평소보다 폭증). + attention_score = 최근 7일 전체 뉴스 건수 (중립 포함) — 50건 이상이면 100 cap, log 스케일. + """ + now = now or datetime.now(timezone.utc) + row = await conn.fetchrow(""" + SELECT + COUNT(*) FILTER (WHERE analyzed_at >= $2) AS recent7, + COUNT(*) FILTER (WHERE analyzed_at >= $3 AND analyzed_at < $2) AS prev28 + FROM news_analysis WHERE primary_stock=$1 + """, stock_code, now - timedelta(days=7), now - timedelta(days=35)) + recent7 = int(row["recent7"] or 0) + prev28 = int(row["prev28"] or 0) + rate_recent = recent7 / 7.0 + rate_prev = max(prev28 / 28.0, 0.05) + surge = rate_recent / rate_prev + surge = max(0.0, min(10.0, surge)) + attention = min(100.0, _math.log1p(recent7) * 25.0) # 0건→0, 7건→55, 30건→85, 50건→100 + return float(surge), float(attention) + + +async def calc_market_sentiment_baseline(conn, week_ago: date) -> float: + """전체 시장 종목당 평균 가중 sentiment (sentiment_alpha 산출용 baseline).""" + week_ago_dt = datetime.combine(week_ago, datetime.min.time(), tzinfo=timezone.utc) + row = await conn.fetchrow(""" + SELECT AVG(per_stock)::float AS mean FROM ( + SELECT primary_stock, + SUM(CASE + WHEN sentiment='호재' THEN intensity * 5.0 + WHEN sentiment='악재' THEN -intensity * 5.0 + ELSE 0 END) AS per_stock + FROM news_analysis + WHERE analyzed_at >= $1 AND primary_stock IS NOT NULL + AND sentiment IN ('호재','악재') + GROUP BY primary_stock + ) t + """, week_ago_dt) + return float((row and row["mean"]) or 0.0) # ── H3: KOSPI 200일 데이터 수집 (네이버 finance) ────────── @@ -1391,6 +1524,9 @@ async def calculate_daily_scores(): logger.warning("weights.load_err", error=str(e)) async with pg_pool.acquire() as conn: + # D: 시장 sentiment baseline 1회 계산 (전 종목 sentiment_alpha 산출용) + market_sentiment_baseline = await calc_market_sentiment_baseline(conn, week_ago) + logger.info("sentiment.market_baseline", value=round(market_sentiment_baseline, 2)) # H3: 시장 레짐 1회 계산 (전 종목 동일 적용) regime_label, regime_adj = await calc_market_regime(conn) await conn.execute(""" @@ -1740,9 +1876,17 @@ async def calculate_daily_scores(): if ensemble_summary: fin_reasons.append(f"공식보팅 [{ensemble_summary}]") - # M4: catalyst 가중 뉴스점수로 교체 (위에서 계산한 raw_news 대체) + # M4 + A/C: catalyst 가중 + 시간감쇠 + similar_count 적용된 뉴스 점수 news_score_w, news_stats = await calc_news_score_weighted(conn, stock, week_ago) news_score = news_score_w + # B: 감정 모멘텀 (3일 vs 4일 변화율) + sentiment_momentum = await calc_sentiment_momentum(conn, stock) + # E: 뉴스 surge + attention(중립 포함 총 관심도) + news_surge_ratio, attention_score = await calc_news_surge_and_attention(conn, stock) + # D: 시장 평균 대비 sentiment alpha (per-stock raw sum - 시장 평균) + sentiment_raw_sum = float(news_stats.get("pos", 0)) * 5.0 - float(news_stats.get("neg", 0)) * 5.0 + sentiment_alpha = max(-100.0, min(100.0, + sentiment_raw_sum - market_sentiment_baseline)) # 펀더멘털 통합: 기존 + 추세 + 이익품질 + 매직포뮬러 + F-Score (DCF는 종합점수에 별도 가중) fundamental_combined = max(-100.0, min(100.0, @@ -1754,6 +1898,18 @@ async def calculate_daily_scores(): + technical_score * 0.15 + dart_score * 0.10 + foreign_score * 0.14 + short_score * 0.06 + price_score * 0.03 + mos_score * 0.10) + # B+D: 감정 모멘텀 + 시장 alpha 보너스 (max ±5) + sentiment_bonus = max(-5.0, min(5.0, + sentiment_momentum * 0.06 + sentiment_alpha * 0.03)) + total += sentiment_bonus + if abs(sentiment_bonus) >= 1.5: + fin_reasons.append( + f"감정 {('+' if sentiment_bonus>0 else '')}{sentiment_bonus:.1f}" + f"(모멘텀 {sentiment_momentum:+.1f} · alpha {sentiment_alpha:+.0f})") + # E: news surge ≥3.0 + 뉴스점수 양수 → 강한 attention 신호로 +2 (악재 surge는 차감 안 함) + if news_surge_ratio >= 3.0 and news_score > 10: + total += 2.0 + fin_reasons.append(f"뉴스 surge ×{news_surge_ratio:.1f}") # 앙상블 보팅 가산점: 학습 가중치 적용 (max ±18, 균등 시 6공식 합 = 18) ensemble_bonus = 0.0 for fname, fsig in sig_map.items(): @@ -1832,10 +1988,11 @@ async def calculate_daily_scores(): magic_score, f_score, roc_pct, earnings_yield_pct, altman_z, peg, momentum_pct, beneish_score, signals, buy_votes, sell_votes, - gpa_pct, g_score, amihud_illiq, market_beta) + gpa_pct, g_score, amihud_illiq, market_beta, + sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, $20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34, - $35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45) + $35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49) ON CONFLICT (stock_code, score_date) DO UPDATE SET news_score=$9, dart_score=$12, price_score=$14, technical_score=$15, foreign_score=$16, short_score=$17, @@ -1847,9 +2004,11 @@ async def calculate_daily_scores(): magic_score=$31, f_score=$32, roc_pct=$33, earnings_yield_pct=$34, altman_z=$35, peg=$36, momentum_pct=$37, beneish_score=$38, signals=$39, buy_votes=$40, sell_votes=$41, - gpa_pct=$42, g_score=$43, amihud_illiq=$44, market_beta=$45 + gpa_pct=$42, g_score=$43, amihud_illiq=$44, market_beta=$45, + sentiment_momentum=$46, sentiment_alpha=$47, + attention_score=$48, news_surge_ratio=$49 """, stock, name, today, - news_stats["pos"], news_stats["neg"], 0, news_stats["total"], + news_stats["pos"], news_stats["neg"], news_stats.get("neutral", 0), news_stats["total"], avg_int, news_score, dart_pos, dart_neg, dart_score, price_change, price_score, technical_score, foreign_score, short_score, @@ -1861,7 +2020,8 @@ async def calculate_daily_scores(): magic_score, f_score, roc_pct, ey_pct, altman_z, peg_val, mom_val, beneish_val, json.dumps(sig_map, ensure_ascii=False), vote_counts["매수"], vote_counts["매도"], - gpa_val, g_val, amihud_val, beta_val) + gpa_val, g_val, amihud_val, beta_val, + sentiment_momentum, sentiment_alpha, attention_score, news_surge_ratio) # 미국증시 overnight 보정값 별도 컬럼 저장 if us_info["adj"] or us_info["top"]: