[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:
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"dangerouslySkipPermissions": true,
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions",
|
||||
"allow": ["Bash(*)", "Edit(*)", "Write(*)", "Read(*)", "Glob(*)", "Grep(*)"]
|
||||
},
|
||||
"instructions": "코드만 출력, 설명은 주석으로. 수정은 변경된 함수/diff 단위만. 답변 마지막에 1줄 요약. 상세 설명은 요청 시에만. 수정 후 반드시 git commit/push.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
|
||||
@@ -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] 전일 날짜 계산 수정 (월요일 → 금요일)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 완료"
|
||||
Reference in New Issue
Block a user