From 0cb1b736a9da6527988810b6309343ea8a9feb75 Mon Sep 17 00:00:00 2001 From: kyu Date: Wed, 3 Jun 2026 12:42:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CIO=20=EB=A7=A4=EB=8F=84=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=EC=95=88=20+=20=EA=B2=B0=EC=A0=95=EC=95=88=20?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=EC=B6=94=EC=A0=81=20+=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - score-engine: run_cio_decisions에 보유종목(user_portfolio) 매도 결정안(등급악화/손절) 추가, 모든 결정안에 entry_price 기록. verify_decisions_job(매일18:10)이 7일 실측 수익·알파·정답여부 채점 → /decisions/accuracy (자동실행 게이트, "회사 결정이 실제 맞았나"). - dashboard-api: /api/data-health·accuracy·hot-validate·decisions·decisions-accuracy 프록시. Co-Authored-By: Claude Opus 4.8 (1M context) --- dashboard-api/main.py | 27 ++++++++++ score-engine/main.py | 118 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/dashboard-api/main.py b/dashboard-api/main.py index 6704709..41171f6 100644 --- a/dashboard-api/main.py +++ b/dashboard-api/main.py @@ -2084,6 +2084,33 @@ async def proxy_sector_concentration(): return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration") +# ── AI 투자조직 패널 (데이터건강·정확도·핫검증·결정안) ────────────── +@app.get("/api/data-health") +async def proxy_data_health(): + """데이터 무결성 (score-engine /data-health)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/data-health") + +@app.get("/api/accuracy") +async def proxy_accuracy(days: int = Query(default=90, ge=7, le=365)): + """추천 등급별 정확도 (score-engine /accuracy)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/accuracy?days={days}") + +@app.get("/api/hot-validate") +async def proxy_hot_validate(): + """핫종목 검증 (score-engine /hot/validate)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/hot/validate", timeout=20.0) + +@app.get("/api/decisions") +async def proxy_decisions(): + """오늘의 결정안 (score-engine /decisions)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions") + +@app.get("/api/decisions-accuracy") +async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)): + """결정안 실측 성과 (score-engine /decisions/accuracy)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}") + + async def _enrich_kr_names(rows): """rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부""" if not isinstance(rows, list) or not rows: diff --git a/score-engine/main.py b/score-engine/main.py index db268e0..da0a74f 100644 --- a/score-engine/main.py +++ b/score-engine/main.py @@ -3056,6 +3056,10 @@ async def startup(): scheduler.add_job(cio_decisions_job, "cron", day_of_week="mon-fri", hour=9, minute=20, id="cio_decisions", replace_existing=True) + # 결정안 성과 채점: 매일 18:10 (성과가격 갱신 18:00 이후) + scheduler.add_job(verify_decisions_job, "cron", + hour=18, minute=10, + id="verify_decisions", replace_existing=True) # 성과 추적: 매일 18시 가격 업데이트 scheduler.add_job(update_performance_prices, "cron", day_of_week="mon-fri", hour=18, minute=0, @@ -3353,7 +3357,8 @@ def _conviction(score: float, votes: int) -> int: return 1 async def run_cio_decisions(top: int = 8) -> dict: - """점수+보팅+리스크+핫검증 종합 → 오늘의 매수 결정안 생성·저장 (dry-run, 승인 전).""" + """점수+보팅+리스크+핫검증 종합 → 오늘의 매수/매도 결정안 (dry-run, 승인 전). + 매수=추천 상위 후보, 매도=보유종목(user_portfolio) 등급악화·손절. entry_price 기록(사후추적).""" today = date.today() async with pg_pool.acquire() as conn: await conn.execute(""" @@ -3373,20 +3378,29 @@ async def run_cio_decisions(top: int = 8) -> dict: UNIQUE(decision_date, stock_code) ) """) + for col in ("entry_price BIGINT DEFAULT 0", "return_7d FLOAT", + "kospi_return_7d FLOAT", "alpha_7d FLOAT", "outcome VARCHAR(10)"): + await conn.execute(f"ALTER TABLE daily_decisions ADD COLUMN IF NOT EXISTS {col}") + 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() + + async def _cur_price(code): + return await conn.fetchval( + "SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", code) + + saved = [] + # ── 매수 결정안 (추천 상위 후보) ── 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 + 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) @@ -3397,21 +3411,55 @@ async def run_cio_decisions(top: int = 8) -> dict: 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] + ep = await _cur_price(c["stock_code"]) or 0 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 + (decision_date,stock_code,stock_name,action,conviction,size_pct, + total_score,thesis,risk_notes,entry_price,status) + VALUES ($1,$2,$3,'매수',$4,$5,$6,$7,$8,$9,'proposed') + ON CONFLICT (decision_date,stock_code) DO UPDATE SET + action='매수',conviction=$4,size_pct=$5,total_score=$6, + thesis=$7,risk_notes=$8,entry_price=$9 """, today, c["stock_code"], c["name"], conv, size, - c["total_score"], thesis, " · ".join(risk)) + c["total_score"], (c["top_reasons"] or "")[:300], " · ".join(risk), int(ep)) 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} + "risk": " · ".join(risk)}) + # ── 매도 결정안 (보유종목 등급악화/손절) ── + holds = await conn.fetch(""" + SELECT p.stock_code, p.stock_name, p.buy_price, + s.total_score, s.recommendation, s.sell_votes, s.top_reasons + FROM user_portfolio p + LEFT JOIN stock_scores s ON s.stock_code=p.stock_code AND s.score_date=$1 + WHERE p.active = true + """, today) + for h in holds: + cp = await _cur_price(h["stock_code"]) or 0 + loss = ((cp - h["buy_price"]) / h["buy_price"] * 100) if (h["buy_price"] and cp) else 0 + reco = h["recommendation"]; reasons = []; sell = False + if reco in ("강력매도", "매도관심"): sell = True; reasons.append(f"등급 {reco}") + if loss <= -8: sell = True; reasons.append(f"손절({loss:.0f}%)") + if (h["sell_votes"] or 0) >= 3: sell = True; reasons.append(f"매도보팅{h['sell_votes']}") + if not sell: continue + conv = 5 if (reco == "강력매도" or loss <= -12) else 3 + await conn.execute(""" + INSERT INTO daily_decisions + (decision_date,stock_code,stock_name,action,conviction,size_pct, + total_score,thesis,risk_notes,entry_price,status) + VALUES ($1,$2,$3,'매도',$4,0,$5,$6,$7,$8,'proposed') + ON CONFLICT (decision_date,stock_code) DO UPDATE SET + action='매도',conviction=$4,total_score=$5,thesis=$6,risk_notes=$7,entry_price=$8 + """, today, h["stock_code"], h["stock_name"], conv, + h["total_score"], (h["top_reasons"] or "")[:200], " · ".join(reasons), int(cp)) + saved.append({"code": h["stock_code"], "name": h["stock_name"], "action": "매도", + "conviction": conv, "size_pct": 0, + "score": round(h["total_score"], 1) if h["total_score"] is not None else None, + "risk": " · ".join(reasons)}) + return {"decision_date": str(today), "regime": regime_label, "count": len(saved), + "buy": sum(1 for x in saved if x["action"] == "매수"), + "sell": sum(1 for x in saved if x["action"] == "매도"), + "decisions": saved} @app.post("/decisions/generate") async def decisions_generate(top: int = Query(default=8, ge=1, le=20)): @@ -3428,6 +3476,50 @@ async def decisions_get(): """) return [dict(r) for r in rows] +async def verify_decisions_job(): + """결정안 7일 성과 채점 (return/alpha/정답여부) — '회사 결정이 실제 맞았나'. 매일 18:10.""" + scored = 0 + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, stock_code, action, entry_price, decision_date + FROM daily_decisions + WHERE return_7d IS NULL AND entry_price > 0 + AND decision_date <= CURRENT_DATE - 7 AND decision_date >= CURRENT_DATE - 60 + """) + for r in rows: + target = r["decision_date"] + timedelta(days=7) + if target > date.today(): continue + price = await _close_near(conn, r["stock_code"], target) + if price is None: continue + ret = (price - r["entry_price"]) / r["entry_price"] * 100 + kret = await _kospi_return_between(conn, r["decision_date"], target) + alpha = (ret - kret) if kret is not None else None + outcome = None + if alpha is not None: + outcome = ("정답" if alpha > 0 else "오답") if r["action"] == "매수" \ + else ("정답" if alpha < 0 else "오답") + await conn.execute("""UPDATE daily_decisions + SET return_7d=$1, kospi_return_7d=$2, alpha_7d=$3, outcome=$4 WHERE id=$5""", + ret, kret, alpha, outcome, r["id"]) + scored += 1 + logger.info("verify_decisions.done", scored=scored) + +@app.get("/decisions/accuracy") +async def decisions_accuracy(days: int = Query(default=60, ge=7, le=365)): + """CIO 결정안의 실측 성과 — 자동실행 게이트.""" + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT action, COUNT(*) n, AVG(return_7d) ret, AVG(alpha_7d) alpha, + AVG(CASE WHEN outcome='정답' THEN 1.0 ELSE 0 END) hit + FROM daily_decisions + WHERE return_7d IS NOT NULL AND decision_date >= CURRENT_DATE - ($1::int) + GROUP BY action + """, days) + return {"days": days, "by_action": [ + {"action": r["action"], "n": r["n"], + "avg_return7": round(r["ret"] or 0, 2), "avg_alpha7": round(r["alpha"] or 0, 2), + "hit_rate": round(100 * (r["hit"] or 0))} for r in rows]} + async def cio_decisions_job(): """평일 09:20 — CIO 오늘의 결정안 생성 + 텔레그램 보고 (dry-run, 자동실행 OFF).""" try: