feat: 데이터 토대 + 정확도 검증 (AI 투자조직 Phase 0~1)
- score-engine: 일간리포트 텔레그램을 notify=True(16:30 1회)로 게이팅 → 호출마다 발신되던 폭주 제거. 데이터 무결성 모니터(/data-health + 평일 매시간 경고). 정확도 검증 하베스트(/accuracy + 주간 리포트) — 추천 등급별 실측 알파/적중률. - ta-engine: job_analyze가 is_active=true 전 활성종목 시총순 커버(장중 상위500· 장마감 전종목). 기존 LIMIT 500·무필터로 LS 등 누락되던 버그 수정. - docs/ai_org.md: 데이터우선 마스터 기획(데이터→검증→지능→실행). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+121
-7
@@ -2061,8 +2061,10 @@ def calc_valuation_percentile(per_history: list, cur_per: float) -> tuple[float,
|
||||
return 0.0, ""
|
||||
|
||||
|
||||
async def calculate_daily_scores(as_of: date | None = None):
|
||||
async def calculate_daily_scores(as_of: date | None = None, notify: bool = False):
|
||||
"""일간 점수 계산. as_of=None이면 today (운영 모드), as_of=date이면 그 시점 기준 (백필 모드).
|
||||
notify=True일 때만 텔레그램 일간리포트/신규강력매수 발신 (16:30 정기 1회만 True).
|
||||
그 외 호출(통합 워크플로우 30분마다·매시간 스코어·18:30 등)은 silent로 점수만 갱신.
|
||||
백필 모드는 look-ahead bias 차단:
|
||||
- 사후 학습 캐시(reliability/source_credibility) 미적용
|
||||
- weight_config는 config_date <= as_of 필터
|
||||
@@ -2818,7 +2820,7 @@ async def calculate_daily_scores(as_of: date | None = None):
|
||||
logger.info("scoring.done", scored=scored, recommended=len(rec_rows))
|
||||
|
||||
# 신규 강력매수 즉시 알림 (어제는 강력매수 아니었는데 오늘 새로 등장한 종목)
|
||||
if not backfill_mode and strong_buy:
|
||||
if notify and not backfill_mode and strong_buy:
|
||||
try:
|
||||
async with pg_pool.acquire() as nc:
|
||||
prev_codes = {r["stock_code"] for r in await nc.fetch("""
|
||||
@@ -2853,8 +2855,8 @@ async def calculate_daily_scores(as_of: date | None = None):
|
||||
except Exception as e:
|
||||
logger.warning("new_buy.err", error=str(e))
|
||||
|
||||
# 텔레그램 알림
|
||||
if strong_buy or strong_sell:
|
||||
# 텔레그램 알림 (notify=True 정기 1회만 — 매 호출 발신 방지)
|
||||
if notify and (strong_buy or strong_sell):
|
||||
lines = [f"<b>📊 Trading AI 일간 리포트 ({today})</b>\n"]
|
||||
if strong_buy:
|
||||
lines.append("🟢 <b>강력매수 추천 (버핏 가치필터 통과)</b>")
|
||||
@@ -3027,7 +3029,7 @@ async def startup():
|
||||
redis_cl = aioredis.Redis(
|
||||
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True)
|
||||
await init_db()
|
||||
scheduler.add_job(calculate_daily_scores, "cron",
|
||||
scheduler.add_job(lambda: calculate_daily_scores(notify=True), "cron",
|
||||
day_of_week="mon-fri", hour=16, minute=30,
|
||||
id="daily_score", replace_existing=True)
|
||||
# 텔레그램 정기 알림 하루 2회로 축소 (사용자 요청 2026-06-02):
|
||||
@@ -3038,6 +3040,14 @@ async def startup():
|
||||
scheduler.add_job(cleanup_old_data, "cron",
|
||||
hour=4, minute=0,
|
||||
id="cleanup", replace_existing=True)
|
||||
# 데이터 무결성 모니터: 평일 10~17시 매시간 (RED면 텔레그램 경고, 3h 쓰로틀)
|
||||
scheduler.add_job(data_health_monitor_job, "cron",
|
||||
day_of_week="mon-fri", hour="10-17", minute=5,
|
||||
id="data_health", replace_existing=True)
|
||||
# 정확도 검증 리포트: 매주 일요일 10시 (방식이 실측 대비 맞는지)
|
||||
scheduler.add_job(accuracy_report_job, "cron",
|
||||
day_of_week="sun", hour=10, minute=0,
|
||||
id="accuracy_report", replace_existing=True)
|
||||
# 성과 추적: 매일 18시 가격 업데이트
|
||||
scheduler.add_job(update_performance_prices, "cron",
|
||||
day_of_week="mon-fri", hour=18, minute=0,
|
||||
@@ -3144,13 +3154,117 @@ async def cleanup_old_data():
|
||||
prices=deleted_prices, recs=deleted_recs,
|
||||
news=deleted_news, signals=deleted_signals)
|
||||
|
||||
# ── 데이터 무결성 모니터 ("데이터 제대로 쌓이는지" 상시 감시) ──────────
|
||||
_last_health_alert: dict = {} # 동일 결함 반복경고 방지 (3시간 쓰로틀)
|
||||
|
||||
async def check_data_health() -> dict:
|
||||
"""핵심 데이터 신선도·커버리지 점검 → 항목별 GREEN/YELLOW/RED."""
|
||||
checks = []
|
||||
def add(name, status, detail): checks.append({"name": name, "status": status, "detail": detail})
|
||||
async with pg_pool.acquire() as c:
|
||||
n = await c.fetchval("SELECT COUNT(DISTINCT stock_code) FROM stock_prices WHERE collected_at::date=CURRENT_DATE") or 0
|
||||
add("시세(stock_prices)", "GREEN" if n>=2000 else "YELLOW" if n>=500 else "RED", f"오늘 {n}종목")
|
||||
|
||||
last_dt = await c.fetchval("SELECT MAX(dt) FROM stock_ohlcv WHERE stock_code<>'KOSPI'")
|
||||
ocnt = await c.fetchval("SELECT COUNT(DISTINCT stock_code) FROM stock_ohlcv WHERE dt=$1", last_dt) if last_dt else 0
|
||||
oage = (date.today()-last_dt).days if last_dt else 999
|
||||
add("일봉(stock_ohlcv)", "GREEN" if (ocnt>=2000 and oage<=4) else "YELLOW" if ocnt>=1000 else "RED", f"{last_dt} {ocnt}종목")
|
||||
|
||||
n = await c.fetchval("SELECT COUNT(*) FROM stock_technical WHERE analyzed_at::date=CURRENT_DATE") or 0
|
||||
add("기술분석(TA)", "GREEN" if n>=1500 else "YELLOW" if n>=300 else "RED", f"오늘 {n}건")
|
||||
|
||||
n = await c.fetchval("SELECT COUNT(*) FROM stock_scores WHERE score_date=CURRENT_DATE") or 0
|
||||
add("점수(stock_scores)", "GREEN" if n>=1000 else "YELLOW" if n>=100 else "RED", f"오늘 {n}종목")
|
||||
|
||||
n = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE analyzed_at > now()-interval '24 hours'") or 0
|
||||
add("뉴스(24h)", "GREEN" if n>=100 else "YELLOW" if n>=10 else "RED", f"{n}건")
|
||||
|
||||
kd = await c.fetchval("SELECT MAX(dt) FROM stock_ohlcv WHERE stock_code='KOSPI'")
|
||||
kage = (date.today()-kd).days if kd else 999
|
||||
add("KOSPI지수 일봉", "GREEN" if kage<=4 else "RED", f"{kd}")
|
||||
worst = "RED" if any(x["status"]=="RED" for x in checks) else ("YELLOW" if any(x["status"]=="YELLOW" for x in checks) else "GREEN")
|
||||
return {"overall": worst, "checks": checks, "checked_at": datetime.now().isoformat()}
|
||||
|
||||
async def data_health_monitor_job():
|
||||
"""평일 장중~마감후 점검. RED 있으면 텔레그램 경고 1건 (3시간 쓰로틀)."""
|
||||
try:
|
||||
h = await check_data_health()
|
||||
except Exception as e:
|
||||
logger.error("data_health.err", error=str(e)); return
|
||||
reds = [x for x in h["checks"] if x["status"]=="RED"]
|
||||
if not reds: return
|
||||
key = ",".join(sorted(x["name"] for x in reds))
|
||||
now = datetime.now()
|
||||
last = _last_health_alert.get(key)
|
||||
if last and (now-last).total_seconds() < 10800: return
|
||||
_last_health_alert[key] = now
|
||||
lines = ["🚨 <b>데이터 무결성 경고</b>"] + [f"❌ {x['name']}: {x['detail']}" for x in reds]
|
||||
lines.append("\n수집/분석 파이프라인 점검 필요")
|
||||
await send_telegram("\n".join(lines))
|
||||
logger.info("data_health.alert", reds=len(reds))
|
||||
|
||||
@app.get("/data-health")
|
||||
async def data_health_endpoint():
|
||||
return await check_data_health()
|
||||
|
||||
# ── 정확도 검증 하베스트 ("방식이 맞는지" 실측 대비) ──────────────────
|
||||
async def compute_accuracy(days: int = 90) -> dict:
|
||||
"""추천 등급별 사후 정확도. recommendation_performance(실측 7d/30d 수익률·알파) 집계.
|
||||
매수계열 알파>0 & 매도계열 알파<0 이면 방식 유효."""
|
||||
async with pg_pool.acquire() as conn:
|
||||
grades = await conn.fetch("""
|
||||
SELECT recommendation rec, COUNT(*) n,
|
||||
AVG(return_7d) ret7, AVG(alpha_7d) a7,
|
||||
AVG(return_30d) ret30, AVG(alpha_30d) a30,
|
||||
AVG(CASE WHEN return_7d>0 THEN 1.0 ELSE 0 END) up7
|
||||
FROM recommendation_performance
|
||||
WHERE return_7d IS NOT NULL AND rec_date >= CURRENT_DATE - ($1::int)
|
||||
GROUP BY recommendation
|
||||
""", days)
|
||||
order = {"강력매수": 0, "매수관심": 1, "관망": 2, "매도관심": 3, "강력매도": 4}
|
||||
rows = sorted([dict(g) for g in grades], key=lambda x: order.get(x["rec"], 9))
|
||||
def wavg(sel, key):
|
||||
tot = sum(r["n"] for r in rows if r["rec"] in sel)
|
||||
s = sum((r[key] or 0) * r["n"] for r in rows if r["rec"] in sel)
|
||||
return round(s / tot, 2) if tot else None
|
||||
buy_a, sell_a = wavg(("강력매수", "매수관심"), "a7"), wavg(("강력매도", "매도관심"), "a7")
|
||||
ok = (buy_a is not None and sell_a is not None and buy_a > 0 and sell_a < 0)
|
||||
return {
|
||||
"days": days,
|
||||
"grades": [{"rec": r["rec"], "n": r["n"],
|
||||
"ret7": round(r["ret7"] or 0, 2), "alpha7": round(r["a7"] or 0, 2),
|
||||
"ret30": round(r["ret30"], 2) if r["ret30"] is not None else None,
|
||||
"alpha30": round(r["a30"], 2) if r["a30"] is not None else None,
|
||||
"up7_pct": round(100 * (r["up7"] or 0))} for r in rows],
|
||||
"buy_alpha7": buy_a, "sell_alpha7": sell_a,
|
||||
"verdict": "양호 (매수 알파>0, 매도 알파<0)" if ok else "교정필요 (매수/매도 변별력 부족)",
|
||||
}
|
||||
|
||||
@app.get("/accuracy")
|
||||
async def accuracy_endpoint(days: int = Query(default=90, ge=7, le=365)):
|
||||
return await compute_accuracy(days)
|
||||
|
||||
async def accuracy_report_job():
|
||||
"""주간 정확도 리포트 — 방식이 실측 대비 맞는지 텔레그램 보고."""
|
||||
try:
|
||||
a = await compute_accuracy(90)
|
||||
except Exception as e:
|
||||
logger.error("accuracy.err", error=str(e)); return
|
||||
lines = ["📈 <b>추천 정확도 리포트 (최근90일·7일 알파)</b>",
|
||||
f"판정: <b>{a['verdict']}</b>",
|
||||
f"매수계열 알파 {a['buy_alpha7']} / 매도계열 알파 {a['sell_alpha7']}\n"]
|
||||
for g in a["grades"]:
|
||||
lines.append(f"{g['rec']}: n{g['n']} 수익{g['ret7']}% 알파{g['alpha7']}% 상승{g['up7_pct']}%")
|
||||
await send_telegram("\n".join(lines))
|
||||
logger.info("accuracy.report.sent", verdict=a["verdict"])
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/score/calculate")
|
||||
async def manual_calc():
|
||||
n = await calculate_daily_scores()
|
||||
async def manual_calc(notify: bool = Query(default=False)):
|
||||
n = await calculate_daily_scores(notify=notify)
|
||||
return {"status": "done", "scored": n}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user