From e33dffb0e143945f3d1f04e792abf3ce2bf0435f Mon Sep 17 00:00:00 2001 From: kyu Date: Wed, 3 Jun 2026 12:32:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=AB=EC=A2=85=EB=AA=A9=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=8C=80=20=E2=80=94=20=ED=82=A4=EC=9B=80=20=ED=95=AB?= =?UTF-8?q?=20=C3=97=20=EA=B0=80=EC=B9=98/=ED=92=88=EC=A7=88=20=EB=85=B8?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=ED=95=84=ED=84=B0=20(dry-run)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /hot/validate: 키움 거래량급증(ka10023) 종목을 ETF/파생·초소형(작전)·영업적자· 분식의심(Beneish≥50)·음수점수로 걸러 '검증통과'만 추림. 평일 09:35 통과분 1회 보고. 현재 핫 30개 중 77%가 ETF/파생 노이즈로 확인. Co-Authored-By: Claude Opus 4.8 (1M context) --- score-engine/main.py | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/score-engine/main.py b/score-engine/main.py index 00a08ae..88d462a 100644 --- a/score-engine/main.py +++ b/score-engine/main.py @@ -3048,6 +3048,10 @@ async def startup(): scheduler.add_job(accuracy_report_job, "cron", day_of_week="sun", hour=10, minute=0, id="accuracy_report", replace_existing=True) + # 핫종목 검증팀: 평일 09:35 검증통과 핫종목 1회 보고 (dry-run) + scheduler.add_job(hot_validate_report_job, "cron", + day_of_week="mon-fri", hour=9, minute=35, + id="hot_validate", replace_existing=True) # 성과 추적: 매일 18시 가격 업데이트 scheduler.add_job(update_performance_prices, "cron", day_of_week="mon-fri", hour=18, minute=0, @@ -3258,6 +3262,84 @@ async def accuracy_report_job(): await send_telegram("\n".join(lines)) logger.info("accuracy.report.sent", verdict=a["verdict"]) +# ── 🔥 핫종목 검증팀 (키움 핫 × 가치/품질 노이즈필터, dry-run) ────────── +async def compute_hot_validate(top: int = 30) -> dict: + """키움 거래량급증(ka10023) × 가치/품질 필터 → 핫한데 가치없는 노이즈 + (ETF·파생·작전·적자·분식·음수점수) 제거 → '검증통과'만 추림.""" + hot = [] + try: + async with httpx.AsyncClient() as cli: + r = await cli.get("http://kis-api:8585/volume-surge", timeout=15) + hot = ((r.json() or {}).get("data") or [])[:top] + except Exception as e: + logger.warning("hot_validate.fetch_err", error=str(e)) + return {"error": "키움 핫종목 조회 실패", "detail": str(e)} + codes = [h["code"] for h in hot if h.get("code")] + if not codes: + return {"hot_count": 0, "passed": [], "noise": [], "watch": []} + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT h.code, c.corp_name, c.is_active, + sc.total_score, sc.recommendation, sc.beneish_score, + pr.market_cap, fin.operating_profit + FROM unnest($1::text[]) AS h(code) + LEFT JOIN dart_corps c ON c.stock_code = h.code + LEFT JOIN LATERAL (SELECT total_score, recommendation, beneish_score + FROM stock_scores WHERE stock_code=h.code + ORDER BY score_date DESC LIMIT 1) sc ON true + LEFT JOIN LATERAL (SELECT market_cap FROM stock_prices WHERE stock_code=h.code + ORDER BY collected_at DESC LIMIT 1) pr ON true + LEFT JOIN LATERAL (SELECT operating_profit FROM dart_financials + WHERE stock_code=h.code AND reprt_code='11011' + ORDER BY bsns_year DESC LIMIT 1) fin ON true + """, codes) + by = {r["code"]: r for r in rows} + passed, noise, watch = [], [], [] + for h in hot: + code = h.get("code"); r = by.get(code) + it = {"code": code, "name": (r["corp_name"] if r and r["corp_name"] else h.get("name", "")), + "change_pct": h.get("change_pct"), "surge_rate": h.get("surge_rate"), + "score": round(r["total_score"], 1) if r and r["total_score"] is not None else None, + "reco": r["recommendation"] if r else None} + if not r or not r["is_active"]: + it["verdict"], it["reason"] = "노이즈", "ETF/파생/비상장(가치판단 불가)"; noise.append(it); continue + mc, op, sc, bn = r["market_cap"] or 0, r["operating_profit"], r["total_score"], r["beneish_score"] + if mc and mc < 10_000_000_000: + it["verdict"], it["reason"] = "노이즈", f"초소형 시총{mc//100000000}억(작전위험)"; noise.append(it); continue + if op is not None and op <= 0: + it["verdict"], it["reason"] = "노이즈", "영업적자"; noise.append(it); continue + if bn is not None and bn >= 50: + it["verdict"], it["reason"] = "노이즈", f"분식의심(Beneish {bn:.0f})"; noise.append(it); continue + if sc is None: + it["verdict"], it["reason"] = "관찰", "점수 미산출"; watch.append(it); continue + if sc < 0: + it["verdict"], it["reason"] = "노이즈", f"투자부적합(점수{sc:.0f})"; noise.append(it); continue + if sc >= 40 and r["recommendation"] in ("강력매수", "매수관심"): + it["verdict"], it["reason"] = "검증통과", f"가치+모멘텀(점수{sc:.0f}·{r['recommendation']})"; passed.append(it); continue + it["verdict"], it["reason"] = "관찰", f"중립(점수{sc:.0f})"; watch.append(it) + return {"hot_count": len(hot), "passed_count": len(passed), + "noise_count": len(noise), "watch_count": len(watch), + "noise_filtered_pct": round(100 * len(noise) / len(hot)) if hot else 0, + "passed": passed, "watch": watch, "noise": noise} + +@app.get("/hot/validate") +async def hot_validate_endpoint(top: int = Query(default=30, ge=5, le=50)): + return await compute_hot_validate(top) + +async def hot_validate_report_job(): + """평일 09:35 — 검증통과 핫종목만 텔레그램 보고 (dry-run, 1일 1회).""" + try: + v = await compute_hot_validate(30) + except Exception as e: + logger.error("hot_validate.err", error=str(e)); return + if v.get("error") or not v.get("passed"): + return + lines = [f"🔥 검증통과 핫종목 (키움 핫 {v['hot_count']}개 중 노이즈 {v['noise_filtered_pct']}% 제거)"] + for p in v["passed"][:10]: + lines.append(f"✅ {p['name']}({p['code']}) {p['change_pct']:+.1f}% — {p['reason']}") + await send_telegram("\n".join(lines)) + logger.info("hot_validate.report.sent", passed=v["passed_count"]) + @app.get("/health") async def health(): return {"status": "ok"}