Files
Stock-trading-programming/app/ai/evening.py
T
whdwo798 a65d7c297b [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>
2026-05-15 14:07:15 +09:00

225 lines
8.2 KiB
Python

"""
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)