feat: 핫종목 검증팀 — 키움 핫 × 가치/품질 노이즈필터 (dry-run)

/hot/validate: 키움 거래량급증(ka10023) 종목을 ETF/파생·초소형(작전)·영업적자·
분식의심(Beneish≥50)·음수점수로 걸러 '검증통과'만 추림. 평일 09:35 통과분 1회 보고.
현재 핫 30개 중 77%가 ETF/파생 노이즈로 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-06-03 12:32:16 +09:00
parent 5c721e6b70
commit e33dffb0e1
+82
View File
@@ -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"🔥 <b>검증통과 핫종목</b> (키움 핫 {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"}