From a65d7c297bcda5f30273e43ef709525729897562 Mon Sep 17 00:00:00 2001 From: jongjae Date: Fri, 15 May 2026 14:07:15 +0900 Subject: [PATCH] =?UTF-8?q?[2026-05-15]=20claude=5Fevening=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20+=20=EA=B6=8C=ED=95=9C=20=EC=98=81=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/ai/evening.py: 장후 데이터 수집 (매매내역/30일통계/실전전환조건) - ~/.claude/commands/evening.md: /evening 슬래시 커맨드 - scripts/run_evening.ps1: claude_evening 실행 스크립트 - 작업 스케줄러 StockBot_Evening 등록 (평일 15:30) - .claude/settings.json: bypassPermissions 추가 (권한 영구 승인) - CLAUDE.md: 권한 섹션 추가, 할 일 목록 업데이트 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 4 + CLAUDE.md | 14 ++- app/ai/evening.py | 224 ++++++++++++++++++++++++++++++++++++++++ scripts/run_evening.ps1 | 16 +++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 app/ai/evening.py create mode 100644 scripts/run_evening.ps1 diff --git a/.claude/settings.json b/.claude/settings.json index 6886892..74fad61 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,9 @@ { "dangerouslySkipPermissions": true, + "permissions": { + "defaultMode": "bypassPermissions", + "allow": ["Bash(*)", "Edit(*)", "Write(*)", "Read(*)", "Glob(*)", "Grep(*)"] + }, "instructions": "코드만 출력, 설명은 주석으로. 수정은 변경된 함수/diff 단위만. 답변 마지막에 1줄 요약. 상세 설명은 요청 시에만. 수정 후 반드시 git commit/push.", "hooks": { "SessionStart": [ diff --git a/CLAUDE.md b/CLAUDE.md index b06eca1..111f8df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # StockBot v3.0 — Claude Code 운영 가이드 -> 최종 수정: 2026-05-15 +> 최종 수정: 2026-05-15 (권한·evening 추가) > 인프라: 로컬 개발 → Synology NAS Docker 이전 예정 > 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true) @@ -20,6 +20,13 @@ --- +## Claude Code 권한 + +이 프로젝트 폴더 내 모든 Bash/파일 작업은 사용자가 **영구 승인**한 상태입니다. +권한 확인 없이 자유롭게 실행하세요. (`.claude/settings.json`에 `bypassPermissions` 적용) + +--- + ## 핵심 설계 원칙 (절대 불변) 1. **14:50 강제 청산** — 하드코딩, 예외 없음 @@ -205,11 +212,14 @@ git push origin main ## 현재 알려진 이슈 및 할 일 ### 다음 단계 -- [ ] `claude_morning` 구현 — 뉴스 크롤링 + daily_context.json 생성 - [ ] WebSocket 전환 — REST 폴링 → KIS WebSocket 실시간 시세 - [ ] NAS Docker 이전 ### 완료 +- [x] `claude_morning` 구현 — 뉴스 크롤링 + KIS 수집 + /morning 슬래시 커맨드 +- [x] `claude_evening` 구현 — 결과 분석 + 실전 전환 체크 + /evening 슬래시 커맨드 +- [x] 작업 스케줄러 자동화 — StockBot_Morning(08:15), StockBot_Bot(07:55), StockBot_Evening(15:30) +- [x] Discord Stop 훅 — 세션 종료 시 이번 세션 커밋만 전송 - [x] SQLite `UPDATE ORDER BY` → 서브쿼리 수정 (order_executor.py) - [x] 전일 데이터 캐시 skip 로직 (has_prev_data) - [x] 전일 날짜 계산 수정 (월요일 → 금요일) diff --git a/app/ai/evening.py b/app/ai/evening.py new file mode 100644 index 0000000..96e55ba --- /dev/null +++ b/app/ai/evening.py @@ -0,0 +1,224 @@ +""" +app/ai/evening.py +장 후 데이터 준비 스크립트 (claude_evening 헬퍼) + +Claude Code headless가 이 스크립트를 실행해 당일 매매 결과와 +최근 30일 통계를 수집한 뒤, 직접 분석해 리포트를 작성한다. + +실행: + python app/ai/evening.py # 요약 출력 + python app/ai/evening.py --print # Claude 분석용 전체 데이터 JSON 출력 +""" + +import json +import math +import os +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + + +def _load_env(): + env_path = Path(".env") + if not env_path.exists(): + return + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k = k.strip() + v = v.strip() + if " #" in v: + v = v[: v.index(" #")] + v = v.strip().strip('"').strip("'") + if k and v and k not in os.environ: + os.environ[k] = v + + +_load_env() + +import sys +ROOT = Path(__file__).parent.parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +DB_PATH = os.getenv("DB_PATH", "data/stockbot.db") +TODAY = datetime.now().strftime("%Y-%m-%d") + + +def _conn(): + return sqlite3.connect(DB_PATH) + + +# ── 오늘 매매 내역 ──────────────────────────────────────────────────────────── + +def get_today_trades() -> list[dict]: + if not Path(DB_PATH).exists(): + return [] + with _conn() as c: + rows = c.execute( + "SELECT ticker, name, entry_time, exit_time, entry_price, exit_price, " + "quantity, exit_reason, pnl, fee, ai_boosted " + "FROM trades WHERE date=? ORDER BY entry_time", + (TODAY,), + ).fetchall() + cols = ["ticker", "name", "entry_time", "exit_time", "entry_price", + "exit_price", "quantity", "exit_reason", "pnl", "fee", "ai_boosted"] + return [dict(zip(cols, r)) for r in rows] + + +# ── 최근 N일 일별 요약 ──────────────────────────────────────────────────────── + +def get_daily_summaries(days: int = 30) -> list[dict]: + if not Path(DB_PATH).exists(): + return [] + since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + with _conn() as c: + rows = c.execute( + "SELECT date, total_trades, win_trades, lose_trades, " + "net_pnl, max_drawdown, trading_stopped " + "FROM daily_summary WHERE date >= ? ORDER BY date", + (since,), + ).fetchall() + cols = ["date", "total_trades", "win_trades", "lose_trades", + "net_pnl", "max_drawdown", "trading_stopped"] + return [dict(zip(cols, r)) for r in rows] + + +# ── 실전 전환 조건 계산 ────────────────────────────────────────────────────── + +def calc_live_ready(summaries: list[dict]) -> dict: + """5가지 실전 전환 조건 계산""" + trading_days = len(summaries) + if trading_days == 0: + return { + "trading_days": 0, "win_rate": 0, "mdd_pct": 0, + "sharpe": 0, "l3_count": 0, "all_pass": False, + "details": [] + } + + total_trades = sum(s["total_trades"] for s in summaries) + win_trades = sum(s["win_trades"] for s in summaries) + win_rate = win_trades / total_trades * 100 if total_trades else 0 + + # MDD: 누적 손익 기준 최대 낙폭 + cum = 0.0 + peak = 0.0 + mdd = 0.0 + for s in summaries: + cum += s["net_pnl"] or 0 + if cum > peak: + peak = cum + drawdown = (peak - cum) / max(abs(peak), 1) * 100 + if drawdown > mdd: + mdd = drawdown + + # Sharpe: 일별 손익의 mean/std (연환산) + daily_pnl = [s["net_pnl"] or 0 for s in summaries] + if len(daily_pnl) >= 2: + mean = sum(daily_pnl) / len(daily_pnl) + variance = sum((x - mean) ** 2 for x in daily_pnl) / len(daily_pnl) + std = math.sqrt(variance) if variance > 0 else 1 + sharpe = (mean / std) * math.sqrt(252) + else: + sharpe = 0.0 + + # L3 발동 횟수 (trading_stopped=1인 날) + l3_count = sum(1 for s in summaries if s.get("trading_stopped")) + + checks = [ + ("누적 운영 30거래일", trading_days >= 30, f"{trading_days}일"), + ("승률 > 48%", win_rate > 48, f"{win_rate:.1f}%"), + ("MDD < -10%", mdd < 10, f"-{mdd:.1f}%"), + ("샤프 > 1.0", sharpe > 1.0, f"{sharpe:.2f}"), + ("L3 월 2회 이하", l3_count <= 2, f"{l3_count}회"), + ] + + return { + "trading_days": trading_days, + "win_rate": round(win_rate, 1), + "mdd_pct": round(-mdd, 1), + "sharpe": round(sharpe, 2), + "l3_count": l3_count, + "all_pass": all(c[1] for c in checks), + "details": [{"name": c[0], "pass": c[1], "value": c[2]} for c in checks], + } + + +# ── 최근 로그 ───────────────────────────────────────────────────────────────── + +def get_today_log_tail(lines: int = 50) -> str: + log_path = Path("logs/stockbot.log") + if not log_path.exists(): + return "" + all_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + today_lines = [l for l in all_lines if TODAY in l] + return "\n".join(today_lines[-lines:]) + + +# ── 최근 30일 리포트 목록 ───────────────────────────────────────────────────── + +def get_recent_reports(days: int = 5) -> list[str]: + reports_dir = Path("reports/daily") + if not reports_dir.exists(): + return [] + files = sorted(reports_dir.glob("*.md"), reverse=True)[:days] + return [f.name for f in files] + + +# ── 메인 ───────────────────────────────────────────────────────────────────── + +def main(print_mode: bool = False): + trades = get_today_trades() + summaries = get_daily_summaries(30) + live = calc_live_ready(summaries) + log_tail = get_today_log_tail(30) + reports = get_recent_reports(5) + + # 오늘 요약 통계 + closed = [t for t in trades if t["exit_time"]] + wins = [t for t in closed if (t["pnl"] or 0) > 0] + losses = [t for t in closed if (t["pnl"] or 0) <= 0] + net_pnl = sum(t["pnl"] or 0 for t in closed) + + summary = { + "date": TODAY, + "today": { + "total_trades": len(closed), + "wins": len(wins), + "losses": len(losses), + "win_rate": round(len(wins) / len(closed) * 100, 1) if closed else 0, + "net_pnl": round(net_pnl), + "trades": trades, + }, + "last_30_days": { + "trading_days": live["trading_days"], + "win_rate": live["win_rate"], + "mdd_pct": live["mdd_pct"], + "sharpe": live["sharpe"], + "l3_count": live["l3_count"], + "daily_summaries": summaries[-10:], # 최근 10일만 (토큰 절약) + }, + "live_ready": live, + "recent_reports": reports, + "log_tail": log_tail, + } + + if print_mode: + print(json.dumps(summary, ensure_ascii=False, indent=2)) + else: + # 콘솔 요약 출력 + print(f"\n[{TODAY}] 장 후 결산") + print(f" 매매: {len(closed)}회 / 승 {len(wins)} 패 {len(losses)} " + f"({summary['today']['win_rate']}%) / {net_pnl:+,.0f}원") + print(f" 실전 전환: {'✅ ALL PASS' if live['all_pass'] else '❌ 미충족'}") + for d in live["details"]: + mark = "✅" if d["pass"] else "❌" + print(f" {mark} {d['name']}: {d['value']}") + + +if __name__ == "__main__": + main(print_mode="--print" in sys.argv) diff --git a/scripts/run_evening.ps1 b/scripts/run_evening.ps1 new file mode 100644 index 0000000..4821dee --- /dev/null +++ b/scripts/run_evening.ps1 @@ -0,0 +1,16 @@ +# claude_evening 실행 스크립트 +# 작업 스케줄러에서 15:30에 실행 (평일) + +$PROJECT = "C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3" +$LOG = "$PROJECT\logs\evening.log" + +Set-Location $PROJECT + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +Add-Content $LOG "[$timestamp] claude_evening 시작" + +# Claude Code headless 실행 +claude -p "/evening" --dangerously-skip-permissions *>> $LOG + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +Add-Content $LOG "[$timestamp] claude_evening 완료"