feat: 추천 정확도 측정 신뢰화 (median 집계 + 30일 verdict + OHLCV 이상치 감지)

- /accuracy 집계를 AVG→중앙값(percentile_cont): 동전주·불량데이터 이상치가
  평균을 왜곡해 강력매도가 +12% 띄던 가짜 역전 제거 (중앙값은 정상 변별)
- verdict를 7일→30일 기준 3티어로(양호/부분유효/교정필요) + 30일 표본부족 폴백.
  응답에 buy_alpha30/sell_alpha30/spread30/basis 추가
- data-health에 OHLCV 이상치 감지룰 추가: KRX ±30% 일일제한 초과(>0.35)는
  정의상 불량(스케일버그·권리락). YELLOW 경고, 급증시 RED

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-06-04 00:27:31 +09:00
parent 3eab710dce
commit 0c8724a542
+54 -12
View File
@@ -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 = ["📈 <b>추천 정확도 리포트 (최근90일·7일 알파)</b>",
lines = ["📈 <b>추천 정확도 리포트 (최근90일·30일 알파·중앙값)</b>",
f"판정: <b>{a['verdict']}</b>",
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"])