감성평가 정확성 강화 (A+B+C+D+E)

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) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-05-20 22:54:17 +09:00
parent 324c7a4b95
commit 47e7a5fb66
3 changed files with 223 additions and 26 deletions
+35
View File
@@ -1730,6 +1730,11 @@ async function renderFormulas(){
<th title="Mohanram G-Score (vs 섹터 중앙값)">G</th>
<th title="Amihud 비유동성">Amh</th>
<th title="저베타 알파 (Frazzini-Pedersen BAB)">β</th>
<th title="catalyst×감쇠×중복 가중 뉴스점수 (-100~100)">뉴스</th>
<th title="감정 모멘텀: 최근 3일 vs 이전 4일 변화율">M</th>
<th title="시장 평균 대비 sentiment alpha">α</th>
<th title="최근 7일 뉴스건수 log 스케일 관심도 (0~100)">관심</th>
<th title="평소 대비 뉴스 폭증 배율 (x)">×</th>
</tr></thead><tbody>`;
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 '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=10?'#69F0AE':v<=-10?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c};font-weight:700">${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // 모멘텀
const v = r.sentiment_momentum;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(1)}</td>`;
})()}
${(()=>{ // alpha
const v = r.sentiment_alpha;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=5?'#69F0AE':v<=-5?'#FF8A80':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${v>0?'+':''}${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // attention
const v = r.attention_score;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=70?'#FFD740':v>=40?'#69F0AE':'#90A4AE';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(0)}</td>`;
})()}
${(()=>{ // surge
const v = r.news_surge_ratio;
if(v==null) return '<td style="text-align:center;color:#37474F">-</td>';
const c = v>=3?'#FFD740':v>=1.5?'#69F0AE':'#546E7A';
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${c}">${(+v).toFixed(1)}×</td>`;
})()}
</tr>`;
});
h += `</tbody></table></div>`;
+3 -1
View File
@@ -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)
+185 -25
View File
@@ -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"]: