감성평가 정확성 강화 (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:
@@ -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>`;
|
||||
|
||||
@@ -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
@@ -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"]:
|
||||
|
||||
Reference in New Issue
Block a user