From 84fd3833fff52165e376818b169c1962a3b1bce8 Mon Sep 17 00:00:00 2001 From: kyu Date: Wed, 3 Jun 2026 12:34:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CIO=20=EC=A2=85=ED=95=A9=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=E2=80=94=20=EC=98=A4=EB=8A=98?= =?UTF-8?q?=EC=9D=98=20=EA=B2=B0=EC=A0=95=EC=95=88=20(dry-run)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /decisions/generate + /decisions: 점수+보팅+핫검증+시장레짐 종합해 매수 결정안 (확신도1~5·제안비중·논거·리스크노트) 생성→daily_decisions 저장. 약세장 사이즈 축소, 분식의심/이익품질/핫검증 통과를 리스크노트로. 평일 09:20 텔레그램 보고(자동실행 OFF). Co-Authored-By: Claude Opus 4.8 (1M context) --- score-engine/main.py | 104 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/score-engine/main.py b/score-engine/main.py index 88d462a..db268e0 100644 --- a/score-engine/main.py +++ b/score-engine/main.py @@ -3052,6 +3052,10 @@ async def startup(): scheduler.add_job(hot_validate_report_job, "cron", day_of_week="mon-fri", hour=9, minute=35, id="hot_validate", replace_existing=True) + # CIO 오늘의 결정안: 평일 09:20 생성+보고 (dry-run, 자동실행 OFF) + scheduler.add_job(cio_decisions_job, "cron", + day_of_week="mon-fri", hour=9, minute=20, + id="cio_decisions", replace_existing=True) # 성과 추적: 매일 18시 가격 업데이트 scheduler.add_job(update_performance_prices, "cron", day_of_week="mon-fri", hour=18, minute=0, @@ -3340,6 +3344,106 @@ async def hot_validate_report_job(): await send_telegram("\n".join(lines)) logger.info("hot_validate.report.sent", passed=v["passed_count"]) +# ── 🧭 CIO 종합 에이전트 (오늘의 결정안, dry-run) ────────────────── +def _conviction(score: float, votes: int) -> int: + if score >= 70 and votes >= 3: return 5 + if score >= 55 and votes >= 2: return 4 + if score >= 45: return 3 + if score >= 35: return 2 + return 1 + +async def run_cio_decisions(top: int = 8) -> dict: + """점수+보팅+리스크+핫검증 종합 → 오늘의 매수 결정안 생성·저장 (dry-run, 승인 전).""" + today = date.today() + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS daily_decisions ( + id SERIAL PRIMARY KEY, + decision_date DATE NOT NULL, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + action VARCHAR(20) NOT NULL, + conviction INTEGER DEFAULT 0, + size_pct FLOAT DEFAULT 0, + total_score FLOAT, + thesis TEXT DEFAULT '', + risk_notes TEXT DEFAULT '', + status VARCHAR(20) DEFAULT 'proposed', + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(decision_date, stock_code) + ) + """) + regime_label, _ = await calc_market_regime(conn) + hotv = await compute_hot_validate(30) + hot_pass = {p["code"] for p in (hotv.get("passed") or [])} if not hotv.get("error") else set() + cands = await conn.fetch(""" + SELECT s.stock_code, COALESCE(d.corp_name, s.stock_code) name, + s.total_score, s.recommendation, s.buy_votes, + s.position_size_pct, s.top_reasons, + s.beneish_score, s.earnings_quality + FROM stock_scores s JOIN dart_corps d ON d.stock_code = s.stock_code + WHERE s.score_date = $1 AND d.is_active = true + AND s.recommendation IN ('강력매수', '매수관심') AND s.buy_votes >= 1 + ORDER BY s.total_score DESC LIMIT $2 + """, today, top) + saved = [] + for c in cands: + conv = _conviction(c["total_score"] or 0, c["buy_votes"] or 0) + if c["stock_code"] in hot_pass: conv = min(5, conv + 1) + size = round(c["position_size_pct"] or (conv * 2.0), 1) + if regime_label == "약세": size = round(size * 0.5, 1) + risk = [] + if regime_label == "약세": risk.append("시장 약세(사이즈↓)") + if (c["beneish_score"] or 0) >= 50: risk.append("분식의심") + if c["earnings_quality"] is not None and c["earnings_quality"] < 0: risk.append("이익품질 낮음") + if c["stock_code"] in hot_pass: risk.append("핫종목 검증통과") + thesis = (c["top_reasons"] or "")[:300] + await conn.execute(""" + INSERT INTO daily_decisions + (decision_date, stock_code, stock_name, action, conviction, size_pct, + total_score, thesis, risk_notes, status) + VALUES ($1,$2,$3,'매수',$4,$5,$6,$7,$8,'proposed') + ON CONFLICT (decision_date, stock_code) DO UPDATE SET + conviction=$4, size_pct=$5, total_score=$6, thesis=$7, risk_notes=$8 + """, today, c["stock_code"], c["name"], conv, size, + c["total_score"], thesis, " · ".join(risk)) + saved.append({"code": c["stock_code"], "name": c["name"], "action": "매수", + "conviction": conv, "size_pct": size, + "score": round(c["total_score"], 1) if c["total_score"] is not None else None, + "risk": " · ".join(risk), "thesis": thesis[:120]}) + return {"decision_date": str(today), "regime": regime_label, "count": len(saved), "decisions": saved} + +@app.post("/decisions/generate") +async def decisions_generate(top: int = Query(default=8, ge=1, le=20)): + return await run_cio_decisions(top) + +@app.get("/decisions") +async def decisions_get(): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT stock_code, stock_name, action, conviction, size_pct, total_score, + thesis, risk_notes, status + FROM daily_decisions WHERE decision_date = CURRENT_DATE + ORDER BY conviction DESC, total_score DESC + """) + return [dict(r) for r in rows] + +async def cio_decisions_job(): + """평일 09:20 — CIO 오늘의 결정안 생성 + 텔레그램 보고 (dry-run, 자동실행 OFF).""" + try: + d = await run_cio_decisions(8) + except Exception as e: + logger.error("cio.err", error=str(e)); return + if not d.get("decisions"): return + lines = [f"🧭 오늘의 결정안 ({d['decision_date']}, 시장:{d['regime']}) — dry-run"] + for x in d["decisions"]: + lines.append(f"{'★'*x['conviction']} {x['name']}({x['code']}) {x['action']} " + f"비중{x['size_pct']}% (점수{x['score']})" + + (f"\n ⚠️{x['risk']}" if x['risk'] else "")) + lines.append("\n(자동실행 OFF — 검증 단계. 정확도 양전환 시 실행 연결)") + await send_telegram("\n".join(lines)) + logger.info("cio.report.sent", count=d["count"]) + @app.get("/health") async def health(): return {"status": "ok"}