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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user