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"]: