[2026-05-15] claude_evening 구현 + 권한 영구 적용

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 14:07:15 +09:00
parent e60b59a644
commit a65d7c297b
4 changed files with 256 additions and 2 deletions
+4
View File
@@ -1,5 +1,9 @@
{ {
"dangerouslySkipPermissions": true, "dangerouslySkipPermissions": true,
"permissions": {
"defaultMode": "bypassPermissions",
"allow": ["Bash(*)", "Edit(*)", "Write(*)", "Read(*)", "Glob(*)", "Grep(*)"]
},
"instructions": "코드만 출력, 설명은 주석으로. 수정은 변경된 함수/diff 단위만. 답변 마지막에 1줄 요약. 상세 설명은 요청 시에만. 수정 후 반드시 git commit/push.", "instructions": "코드만 출력, 설명은 주석으로. 수정은 변경된 함수/diff 단위만. 답변 마지막에 1줄 요약. 상세 설명은 요청 시에만. 수정 후 반드시 git commit/push.",
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
+12 -2
View File
@@ -1,6 +1,6 @@
# StockBot v3.0 — Claude Code 운영 가이드 # StockBot v3.0 — Claude Code 운영 가이드
> 최종 수정: 2026-05-15 > 최종 수정: 2026-05-15 (권한·evening 추가)
> 인프라: 로컬 개발 → Synology NAS Docker 이전 예정 > 인프라: 로컬 개발 → Synology NAS Docker 이전 예정
> 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true) > 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true)
@@ -20,6 +20,13 @@
--- ---
## Claude Code 권한
이 프로젝트 폴더 내 모든 Bash/파일 작업은 사용자가 **영구 승인**한 상태입니다.
권한 확인 없이 자유롭게 실행하세요. (`.claude/settings.json``bypassPermissions` 적용)
---
## 핵심 설계 원칙 (절대 불변) ## 핵심 설계 원칙 (절대 불변)
1. **14:50 강제 청산** — 하드코딩, 예외 없음 1. **14:50 강제 청산** — 하드코딩, 예외 없음
@@ -205,11 +212,14 @@ git push origin main
## 현재 알려진 이슈 및 할 일 ## 현재 알려진 이슈 및 할 일
### 다음 단계 ### 다음 단계
- [ ] `claude_morning` 구현 — 뉴스 크롤링 + daily_context.json 생성
- [ ] WebSocket 전환 — REST 폴링 → KIS WebSocket 실시간 시세 - [ ] WebSocket 전환 — REST 폴링 → KIS WebSocket 실시간 시세
- [ ] NAS Docker 이전 - [ ] 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] SQLite `UPDATE ORDER BY` → 서브쿼리 수정 (order_executor.py)
- [x] 전일 데이터 캐시 skip 로직 (has_prev_data) - [x] 전일 데이터 캐시 skip 로직 (has_prev_data)
- [x] 전일 날짜 계산 수정 (월요일 → 금요일) - [x] 전일 날짜 계산 수정 (월요일 → 금요일)
+224
View File
@@ -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)
+16
View File
@@ -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 완료"