diff --git a/score-engine/main.py b/score-engine/main.py
index d67baa4..39ae1f1 100644
--- a/score-engine/main.py
+++ b/score-engine/main.py
@@ -3232,6 +3232,18 @@ async def check_data_health() -> dict:
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}")
+
+ # 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}행")
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()}
@@ -3260,34 +3272,64 @@ async def data_health_endpoint():
# ── 정확도 검증 하베스트 ("방식이 맞는지" 실측 대비) ──────────────────
async def compute_accuracy(days: int = 90) -> dict:
"""추천 등급별 사후 정확도. recommendation_performance(실측 7d/30d 수익률·알파) 집계.
+ 동전주 폭등·불량 가격데이터 이상치가 평균(mean)을 왜곡하므로 중앙값(median)으로 집계.
매수계열 알파>0 & 매도계열 알파<0 이면 방식 유효."""
async with pg_pool.acquire() as conn:
grades = await conn.fetch("""
SELECT recommendation rec, COUNT(*) n,
- AVG(return_7d) ret7, AVG(alpha_7d) a7,
- AVG(return_30d) ret30, AVG(alpha_30d) a30,
+ 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,
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)
+ 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)
order = {"강력매수": 0, "매수관심": 1, "관망": 2, "매도관심": 3, "강력매도": 4}
rows = sorted([dict(g) for g in grades], key=lambda x: order.get(x["rec"], 9))
- def wavg(sel, key):
- tot = sum(r["n"] for r in rows if r["rec"] in sel)
- s = sum((r[key] or 0) * r["n"] for r in rows if r["rec"] in sel)
- return round(s / tot, 2) if tot else None
- buy_a, sell_a = wavg(("강력매수", "매수관심"), "a7"), wavg(("강력매도", "매도관심"), "a7")
- ok = (buy_a is not None and sell_a is not None and buy_a > 0 and sell_a < 0)
+ 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일 변별력 부족)"
return {
"days": days,
+ "agg": "median",
+ "basis": "30d",
"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,
- "verdict": "양호 (매수 알파>0, 매도 알파<0)" if ok else "교정필요 (매수/매도 변별력 부족)",
+ "buy_alpha30": buy_a30, "sell_alpha30": sell_a30, "spread30": spread30,
+ "verdict": verdict,
}
@app.get("/accuracy")
@@ -3300,11 +3342,11 @@ async def accuracy_report_job():
a = await compute_accuracy(90)
except Exception as e:
logger.error("accuracy.err", error=str(e)); return
- lines = ["📈 추천 정확도 리포트 (최근90일·7일 알파)",
+ lines = ["📈 추천 정확도 리포트 (최근90일·30일 알파·중앙값)",
f"판정: {a['verdict']}",
- f"매수계열 알파 {a['buy_alpha7']} / 매도계열 알파 {a['sell_alpha7']}\n"]
+ f"매수계열 알파 {a['buy_alpha30']} / 매도계열 알파 {a['sell_alpha30']} / 강력매수−강력매도 스프레드 {a['spread30']}%p\n"]
for g in a["grades"]:
- lines.append(f"{g['rec']}: n{g['n']} 수익{g['ret7']}% 알파{g['alpha7']}% 상승{g['up7_pct']}%")
+ lines.append(f"{g['rec']}: n{g['n']} 30일수익{g['ret30']}% 알파{g['alpha30']}% (7일알파{g['alpha7']}%)")
await send_telegram("\n".join(lines))
logger.info("accuracy.report.sent", verdict=a["verdict"])