feat: CIO 매도 결정안 + 결정안 성과추적 + 대시보드 표시
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+105
-13
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user