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