- Added functionality to fetch live trade data from the dashboard API. - Implemented weekly report generation that includes backtest results, live trade statistics, and performance trends. - Enhanced error handling for API requests and improved logging for better traceability. - Updated tests to cover new features and ensure reliability of the report generation process.
1072 lines
34 KiB
Markdown
1072 lines
34 KiB
Markdown
# 주간 전략 리포트 구현 계획
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** 매주 전략 성능을 자동 측정하고, 추이를 추적하며, 성능 저하를 감지하고, Discord 리포트를 전송한다.
|
|
|
|
**Architecture:** 단일 스크립트 `scripts/weekly_report.py`가 데이터 수집(subprocess), Walk-Forward 백테스트(import), 로그 파싱(`dashboard/api/log_parser.py` 재사용), 추이 분석(기존 `results/weekly/*.json` 읽기), 선택적 파라미터 스윕(import), Discord 알림(`src/notifier.py` import)을 오케스트레이션한다. 프로덕션 봇 코드 변경 없음.
|
|
|
|
**Tech Stack:** Python 3.12, 기존 backtester/sweep/notifier/log_parser 모듈, `fetch_history.py` subprocess 호출, Discord용 httpx.
|
|
|
|
---
|
|
|
|
### Task 1: 주간 리포트 코어 — 데이터 수집 + 백테스트
|
|
|
|
**Files:**
|
|
- Create: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: `fetch_latest_data()` 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
def test_fetch_latest_data_calls_subprocess():
|
|
"""fetch_latest_data가 심볼별로 fetch_history.py를 호출하는지 확인."""
|
|
from scripts.weekly_report import fetch_latest_data
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
fetch_latest_data(["XRPUSDT", "TRXUSDT"], days=35)
|
|
|
|
assert mock_run.call_count == 2
|
|
# 첫 번째 호출이 XRPUSDT인지 확인
|
|
args_0 = mock_run.call_args_list[0][0][0]
|
|
assert "--symbol" in args_0
|
|
assert "XRPUSDT" in args_0
|
|
assert "--days" in args_0
|
|
assert "35" in args_0
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_fetch_latest_data_calls_subprocess -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.weekly_report'`
|
|
|
|
**Step 3: `run_backtest()` 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_run_backtest_returns_summary():
|
|
"""run_backtest가 심볼별 WF 백테스트를 실행하고 결과를 반환하는지 확인."""
|
|
from scripts.weekly_report import run_backtest
|
|
|
|
mock_result = {
|
|
"summary": {
|
|
"total_trades": 27,
|
|
"total_pnl": 217.0,
|
|
"return_pct": 21.7,
|
|
"win_rate": 66.7,
|
|
"profit_factor": 1.57,
|
|
"max_drawdown_pct": 12.0,
|
|
"sharpe_ratio": 33.3,
|
|
"avg_win": 20.0,
|
|
"avg_loss": -10.0,
|
|
"total_fees": 5.0,
|
|
"close_reasons": {},
|
|
},
|
|
"folds": [],
|
|
"trades": [],
|
|
}
|
|
|
|
with patch("scripts.weekly_report.WalkForwardBacktester") as MockWF:
|
|
MockWF.return_value.run.return_value = mock_result
|
|
result = run_backtest(
|
|
symbols=["XRPUSDT"],
|
|
train_months=3,
|
|
test_months=1,
|
|
params={"atr_sl_mult": 2.0, "atr_tp_mult": 2.0,
|
|
"signal_threshold": 3, "adx_threshold": 25,
|
|
"volume_multiplier": 2.5},
|
|
)
|
|
|
|
assert result["summary"]["profit_factor"] == 1.57
|
|
assert result["summary"]["total_trades"] == 27
|
|
```
|
|
|
|
**Step 4: 최소 구현 작성**
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""
|
|
주간 전략 리포트: 데이터 수집 → WF 백테스트 → 실전 로그 → 추이 → Discord 알림.
|
|
|
|
사용법:
|
|
python scripts/weekly_report.py
|
|
python scripts/weekly_report.py --skip-fetch
|
|
python scripts/weekly_report.py --date 2026-03-07
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from datetime import datetime, date, timedelta
|
|
|
|
from loguru import logger
|
|
|
|
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
|
|
|
|
|
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
|
|
SYMBOLS = ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
|
PROD_PARAMS = {
|
|
"atr_sl_mult": 2.0,
|
|
"atr_tp_mult": 2.0,
|
|
"signal_threshold": 3,
|
|
"adx_threshold": 25,
|
|
"volume_multiplier": 2.5,
|
|
}
|
|
TRAIN_MONTHS = 3
|
|
TEST_MONTHS = 1
|
|
FETCH_DAYS = 35 # 최근 35일 upsert
|
|
|
|
|
|
def fetch_latest_data(symbols: list[str], days: int = FETCH_DAYS) -> None:
|
|
"""심볼별로 fetch_history.py를 subprocess로 호출하여 최신 데이터를 수집한다."""
|
|
script = str(Path(__file__).parent / "fetch_history.py")
|
|
for sym in symbols:
|
|
cmd = [
|
|
sys.executable, script,
|
|
"--symbol", sym,
|
|
"--interval", "15m",
|
|
"--days", str(days),
|
|
]
|
|
logger.info(f"데이터 수집: {sym} (최근 {days}일)")
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
logger.warning(f" {sym} 수집 실패: {result.stderr[:200]}")
|
|
else:
|
|
logger.info(f" {sym} 수집 완료")
|
|
|
|
|
|
def run_backtest(
|
|
symbols: list[str],
|
|
train_months: int,
|
|
test_months: int,
|
|
params: dict,
|
|
) -> dict:
|
|
"""현재 파라미터로 Walk-Forward 백테스트를 실행하고 결과를 반환한다."""
|
|
cfg = WalkForwardConfig(
|
|
symbols=symbols,
|
|
use_ml=False,
|
|
train_months=train_months,
|
|
test_months=test_months,
|
|
**params,
|
|
)
|
|
wf = WalkForwardBacktester(cfg)
|
|
return wf.run()
|
|
```
|
|
|
|
**Step 5: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 2 PASS
|
|
|
|
**Step 6: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): 데이터 수집 및 WF 백테스트 코어 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: 실전 트레이드 로그 파싱 추가
|
|
|
|
**Files:**
|
|
- Modify: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_parse_live_trades_extracts_entries(tmp_path):
|
|
"""봇 로그에서 진입/청산 패턴을 파싱하여 트레이드 리스트를 반환."""
|
|
from scripts.weekly_report import parse_live_trades
|
|
|
|
log_content = """2026-03-01 10:00:00.000 | INFO | src.bot:process_candle:42 - [XRPUSDT] LONG 진입: 가격=2.5000, 수량=100.0, SL=2.4000, TP=2.7000
|
|
2026-03-01 10:15:00.000 | INFO | src.bot:process_candle:42 - [XRPUSDT] 신호: HOLD | 현재가: 2.5500 USDT
|
|
2026-03-01 12:00:00.000 | INFO | src.user_data_stream:_handle_order:80 - [XRPUSDT] 청산 감지(TAKE_PROFIT): exit=2.7000, rp=20.0000, commission=0.2160, net_pnl=19.5680
|
|
"""
|
|
log_file = tmp_path / "bot.log"
|
|
log_file.write_text(log_content)
|
|
|
|
trades = parse_live_trades(str(log_file), days=7)
|
|
assert len(trades) == 1
|
|
assert trades[0]["symbol"] == "XRPUSDT"
|
|
assert trades[0]["side"] == "LONG"
|
|
assert trades[0]["net_pnl"] == pytest.approx(19.568)
|
|
assert trades[0]["close_reason"] == "TAKE_PROFIT"
|
|
|
|
|
|
def test_parse_live_trades_empty_log(tmp_path):
|
|
"""로그 파일이 없으면 빈 리스트 반환."""
|
|
from scripts.weekly_report import parse_live_trades
|
|
|
|
trades = parse_live_trades(str(tmp_path / "nonexistent.log"), days=7)
|
|
assert trades == []
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_parse_live_trades_extracts_entries -v`
|
|
Expected: FAIL — `ImportError: cannot import name 'parse_live_trades'`
|
|
|
|
**Step 3: 구현 작성**
|
|
|
|
`scripts/weekly_report.py`에 추가:
|
|
|
|
```python
|
|
import re
|
|
|
|
# ── 로그 파싱 패턴 (dashboard/api/log_parser.py와 동일) ──────────
|
|
_RE_ENTRY = re.compile(
|
|
r"\[(\w+)\]\s+(LONG|SHORT)\s+진입:\s+가격=([\d.]+),\s+수량=([\d.]+),\s+SL=([\d.]+),\s+TP=([\d.]+)"
|
|
)
|
|
_RE_CLOSE = re.compile(
|
|
r"\[(\w+)\]\s+청산 감지\((\w+)\):\s+exit=([\d.]+),\s+rp=([\d.-]+),\s+commission=([\d.]+),\s+net_pnl=([\d.-]+)"
|
|
)
|
|
_RE_TIMESTAMP = re.compile(r"^(\d{4}-\d{2}-\d{2})\s")
|
|
|
|
|
|
def parse_live_trades(log_path: str, days: int = 7) -> list[dict]:
|
|
"""봇 로그에서 최근 N일간의 진입/청산 기록을 파싱한다."""
|
|
path = Path(log_path)
|
|
if not path.exists():
|
|
return []
|
|
|
|
cutoff = (date.today() - timedelta(days=days)).isoformat()
|
|
open_trades: dict[str, dict] = {} # symbol -> pending trade
|
|
closed_trades: list[dict] = []
|
|
|
|
for line in path.read_text().splitlines():
|
|
# 날짜 필터
|
|
m_ts = _RE_TIMESTAMP.match(line)
|
|
if m_ts and m_ts.group(1) < cutoff:
|
|
continue
|
|
|
|
# 진입
|
|
m = _RE_ENTRY.search(line)
|
|
if m:
|
|
sym, side, price, qty, sl, tp = m.groups()
|
|
open_trades[sym] = {
|
|
"symbol": sym,
|
|
"side": side,
|
|
"entry_price": float(price),
|
|
"quantity": float(qty),
|
|
"sl": float(sl),
|
|
"tp": float(tp),
|
|
"entry_time": m_ts.group(1) if m_ts else "",
|
|
}
|
|
continue
|
|
|
|
# 청산
|
|
m = _RE_CLOSE.search(line)
|
|
if m:
|
|
sym, reason, exit_price, rp, commission, net_pnl = m.groups()
|
|
trade = open_trades.pop(sym, {"symbol": sym, "side": "UNKNOWN"})
|
|
trade.update({
|
|
"close_reason": reason,
|
|
"exit_price": float(exit_price),
|
|
"expected_pnl": float(rp),
|
|
"commission": float(commission),
|
|
"net_pnl": float(net_pnl),
|
|
"exit_time": m_ts.group(1) if m_ts else "",
|
|
})
|
|
closed_trades.append(trade)
|
|
|
|
return closed_trades
|
|
```
|
|
|
|
**Step 4: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 4 PASS
|
|
|
|
**Step 5: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): 실전 트레이드 로그 파서 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: 추이 추적 (이전 리포트 읽기) 추가
|
|
|
|
**Files:**
|
|
- Modify: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_load_trend_reads_previous_reports(tmp_path):
|
|
"""이전 주간 리포트를 읽어 PF/승률/MDD 추이를 반환."""
|
|
from scripts.weekly_report import load_trend
|
|
|
|
# 4주치 리포트 생성
|
|
for i, (pf, wr, mdd) in enumerate([
|
|
(1.31, 48.0, 9.0),
|
|
(1.24, 45.0, 11.0),
|
|
(1.20, 44.0, 12.0),
|
|
(1.18, 43.0, 14.0),
|
|
]):
|
|
d = date(2026, 3, 7) - timedelta(weeks=3 - i)
|
|
report = {
|
|
"date": d.isoformat(),
|
|
"backtest": {"summary": {
|
|
"profit_factor": pf, "win_rate": wr, "max_drawdown_pct": mdd,
|
|
"total_trades": 20,
|
|
}},
|
|
}
|
|
(tmp_path / f"report_{d.isoformat()}.json").write_text(json.dumps(report))
|
|
|
|
trend = load_trend(str(tmp_path), weeks=4)
|
|
assert len(trend["pf"]) == 4
|
|
assert trend["pf"] == [1.31, 1.24, 1.20, 1.18]
|
|
assert trend["pf_declining_3w"] is True
|
|
|
|
|
|
def test_load_trend_empty_dir(tmp_path):
|
|
"""리포트가 없으면 빈 추이 반환."""
|
|
from scripts.weekly_report import load_trend
|
|
|
|
trend = load_trend(str(tmp_path), weeks=4)
|
|
assert trend["pf"] == []
|
|
assert trend["pf_declining_3w"] is False
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_load_trend_reads_previous_reports -v`
|
|
Expected: FAIL
|
|
|
|
**Step 3: 구현 작성**
|
|
|
|
`scripts/weekly_report.py`에 추가:
|
|
|
|
```python
|
|
WEEKLY_DIR = Path("results/weekly")
|
|
|
|
|
|
def load_trend(report_dir: str, weeks: int = 4) -> dict:
|
|
"""이전 주간 리포트에서 PF/승률/MDD 추이를 로드한다."""
|
|
rdir = Path(report_dir)
|
|
if not rdir.exists():
|
|
return {"pf": [], "win_rate": [], "mdd": [], "pf_declining_3w": False}
|
|
|
|
reports = sorted(rdir.glob("report_*.json"))
|
|
recent = reports[-weeks:] if len(reports) >= weeks else reports
|
|
|
|
pf_list, wr_list, mdd_list = [], [], []
|
|
for rpath in recent:
|
|
try:
|
|
data = json.loads(rpath.read_text())
|
|
s = data["backtest"]["summary"]
|
|
pf_list.append(s["profit_factor"])
|
|
wr_list.append(s["win_rate"])
|
|
mdd_list.append(s["max_drawdown_pct"])
|
|
except (json.JSONDecodeError, KeyError):
|
|
continue
|
|
|
|
# PF 3주 연속 하락 체크
|
|
declining = False
|
|
if len(pf_list) >= 3:
|
|
last3 = pf_list[-3:]
|
|
declining = last3[0] > last3[1] > last3[2]
|
|
|
|
return {
|
|
"pf": pf_list,
|
|
"win_rate": wr_list,
|
|
"mdd": mdd_list,
|
|
"pf_declining_3w": declining,
|
|
}
|
|
```
|
|
|
|
**Step 4: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 6 PASS
|
|
|
|
**Step 5: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): 이전 리포트 추이 추적 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: ML 재트리거 체크 + 성능 저하 스윕 추가
|
|
|
|
**Files:**
|
|
- Modify: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_check_ml_trigger_all_met():
|
|
"""3개 조건 모두 충족 시 recommend=True."""
|
|
from scripts.weekly_report import check_ml_trigger
|
|
|
|
result = check_ml_trigger(
|
|
cumulative_trades=200,
|
|
current_pf=0.85,
|
|
pf_declining_3w=True,
|
|
)
|
|
assert result["recommend"] is True
|
|
assert result["met_count"] == 3
|
|
|
|
|
|
def test_check_ml_trigger_none_met():
|
|
"""조건 미충족 시 recommend=False."""
|
|
from scripts.weekly_report import check_ml_trigger
|
|
|
|
result = check_ml_trigger(
|
|
cumulative_trades=50,
|
|
current_pf=1.5,
|
|
pf_declining_3w=False,
|
|
)
|
|
assert result["recommend"] is False
|
|
assert result["met_count"] == 0
|
|
|
|
|
|
def test_run_degradation_sweep_called_when_pf_low():
|
|
"""PF < 1.0이면 스윕을 실행하고 상위 3개 대안을 반환."""
|
|
from scripts.weekly_report import run_degradation_sweep
|
|
|
|
fake_results = [
|
|
{"params": {"atr_sl_mult": 1.5}, "summary": {"profit_factor": 1.15, "total_trades": 30}},
|
|
{"params": {"atr_sl_mult": 1.0}, "summary": {"profit_factor": 1.08, "total_trades": 25}},
|
|
{"params": {"atr_sl_mult": 2.0}, "summary": {"profit_factor": 0.95, "total_trades": 20}},
|
|
]
|
|
|
|
with patch("scripts.weekly_report.run_single_backtest") as mock_bt:
|
|
mock_bt.side_effect = [r["summary"] for r in fake_results]
|
|
with patch("scripts.weekly_report.generate_combinations", return_value=[
|
|
r["params"] for r in fake_results
|
|
]):
|
|
alternatives = run_degradation_sweep(
|
|
symbols=["XRPUSDT"],
|
|
train_months=3,
|
|
test_months=1,
|
|
top_n=3,
|
|
)
|
|
|
|
assert len(alternatives) <= 3
|
|
# PF 내림차순 정렬
|
|
assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"]
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "ml_trigger or degradation" -v`
|
|
Expected: FAIL
|
|
|
|
**Step 3: 구현 작성**
|
|
|
|
`scripts/weekly_report.py`에 추가:
|
|
|
|
```python
|
|
from scripts.strategy_sweep import (
|
|
run_single_backtest,
|
|
generate_combinations,
|
|
PARAM_GRID,
|
|
)
|
|
|
|
ML_TRADE_THRESHOLD = 150
|
|
|
|
|
|
def check_ml_trigger(
|
|
cumulative_trades: int,
|
|
current_pf: float,
|
|
pf_declining_3w: bool,
|
|
) -> dict:
|
|
"""ML 재학습 조건 체크. 3개 중 2개 이상 충족 시 권장."""
|
|
conditions = {
|
|
"cumulative_trades_enough": cumulative_trades >= ML_TRADE_THRESHOLD,
|
|
"pf_below_1": current_pf < 1.0,
|
|
"pf_declining_3w": pf_declining_3w,
|
|
}
|
|
met = sum(conditions.values())
|
|
return {
|
|
"conditions": conditions,
|
|
"met_count": met,
|
|
"recommend": met >= 2,
|
|
"cumulative_trades": cumulative_trades,
|
|
"threshold": ML_TRADE_THRESHOLD,
|
|
}
|
|
|
|
|
|
def run_degradation_sweep(
|
|
symbols: list[str],
|
|
train_months: int,
|
|
test_months: int,
|
|
top_n: int = 3,
|
|
) -> list[dict]:
|
|
"""전체 파라미터 스윕을 실행하고 PF 상위 N개 대안을 반환한다."""
|
|
combos = generate_combinations(PARAM_GRID)
|
|
results = []
|
|
|
|
for params in combos:
|
|
try:
|
|
summary = run_single_backtest(symbols, params, train_months, test_months)
|
|
results.append({"params": params, "summary": summary})
|
|
except Exception as e:
|
|
logger.warning(f"스윕 실패: {e}")
|
|
|
|
results.sort(
|
|
key=lambda r: r["summary"]["profit_factor"]
|
|
if r["summary"]["profit_factor"] != float("inf") else 999,
|
|
reverse=True,
|
|
)
|
|
return results[:top_n]
|
|
```
|
|
|
|
**Step 4: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 9 PASS
|
|
|
|
**Step 5: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): ML 트리거 체크 및 성능 저하 스윕 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Discord 리포트 포맷팅 + 전송 추가
|
|
|
|
**Files:**
|
|
- Modify: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_format_report_normal():
|
|
"""정상 상태(PF >= 1.0) 리포트 포맷."""
|
|
from scripts.weekly_report import format_report
|
|
|
|
report_data = {
|
|
"date": "2026-03-07",
|
|
"backtest": {
|
|
"summary": {
|
|
"profit_factor": 1.24, "win_rate": 45.0,
|
|
"max_drawdown_pct": 12.0, "total_trades": 88,
|
|
},
|
|
"per_symbol": {
|
|
"XRPUSDT": {"profit_factor": 1.57, "total_trades": 27, "win_rate": 66.7},
|
|
"TRXUSDT": {"profit_factor": 1.29, "total_trades": 25, "win_rate": 52.0},
|
|
"DOGEUSDT": {"profit_factor": 1.09, "total_trades": 36, "win_rate": 44.4},
|
|
},
|
|
},
|
|
"live_trades": {"count": 8, "net_pnl": 12.34, "win_rate": 62.5},
|
|
"trend": {"pf": [1.31, 1.24], "win_rate": [48.0, 45.0], "mdd": [9.0, 12.0], "pf_declining_3w": False},
|
|
"ml_trigger": {"recommend": False, "met_count": 0, "conditions": {
|
|
"cumulative_trades_enough": False, "pf_below_1": False, "pf_declining_3w": False,
|
|
}, "cumulative_trades": 47, "threshold": 150},
|
|
"sweep": None,
|
|
}
|
|
|
|
text = format_report(report_data)
|
|
assert "주간 전략 리포트" in text
|
|
assert "1.24" in text
|
|
assert "XRPUSDT" in text
|
|
assert "스윕 불필요" in text or "파라미터 스윕" not in text
|
|
|
|
|
|
def test_format_report_degraded():
|
|
"""PF < 1.0일 때 스윕 결과가 포함되는지 확인."""
|
|
from scripts.weekly_report import format_report
|
|
|
|
report_data = {
|
|
"date": "2026-06-07",
|
|
"backtest": {
|
|
"summary": {
|
|
"profit_factor": 0.87, "win_rate": 38.0,
|
|
"max_drawdown_pct": 22.0, "total_trades": 90,
|
|
},
|
|
"per_symbol": {},
|
|
},
|
|
"live_trades": {"count": 0, "net_pnl": 0, "win_rate": 0},
|
|
"trend": {"pf": [1.1, 1.0, 0.87], "win_rate": [], "mdd": [], "pf_declining_3w": True},
|
|
"ml_trigger": {"recommend": True, "met_count": 3, "conditions": {
|
|
"cumulative_trades_enough": True, "pf_below_1": True, "pf_declining_3w": True,
|
|
}, "cumulative_trades": 182, "threshold": 150},
|
|
"sweep": [
|
|
{"params": {"atr_sl_mult": 2.0, "atr_tp_mult": 2.5, "adx_threshold": 30, "volume_multiplier": 2.5, "signal_threshold": 3},
|
|
"summary": {"profit_factor": 1.15, "total_trades": 30}},
|
|
],
|
|
}
|
|
|
|
text = format_report(report_data)
|
|
assert "0.87" in text
|
|
assert "ML" in text
|
|
assert "1.15" in text # 스윕 대안
|
|
|
|
|
|
def test_send_report_uses_notifier():
|
|
"""Discord 웹훅으로 리포트를 전송."""
|
|
from scripts.weekly_report import send_report
|
|
|
|
with patch("scripts.weekly_report.DiscordNotifier") as MockNotifier:
|
|
instance = MockNotifier.return_value
|
|
send_report("test report content", webhook_url="https://example.com/webhook")
|
|
instance._send.assert_called_once_with("test report content")
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "format_report or send_report" -v`
|
|
Expected: FAIL
|
|
|
|
**Step 3: 구현 작성**
|
|
|
|
`scripts/weekly_report.py`에 추가:
|
|
|
|
```python
|
|
import os
|
|
from src.notifier import DiscordNotifier
|
|
|
|
|
|
def format_report(data: dict) -> str:
|
|
"""리포트 데이터를 Discord 메시지 텍스트로 포맷한다."""
|
|
d = data["date"]
|
|
bt = data["backtest"]["summary"]
|
|
pf = bt["profit_factor"]
|
|
pf_str = f"{pf:.2f}" if pf != float("inf") else "INF"
|
|
|
|
status = ""
|
|
if pf < 1.0:
|
|
status = " 🚨 손실 구간"
|
|
|
|
lines = [
|
|
f"📊 주간 전략 리포트 ({d})",
|
|
"",
|
|
f"[현재 성능 — Walk-Forward 백테스트]",
|
|
f" 합산 PF: {pf_str} | 승률: {bt['win_rate']:.0f}% | MDD: {bt['max_drawdown_pct']:.0f}%{status}",
|
|
]
|
|
|
|
# 심볼별 성능
|
|
per_sym = data["backtest"].get("per_symbol", {})
|
|
if per_sym:
|
|
sym_parts = []
|
|
for sym, s in per_sym.items():
|
|
short = sym.replace("USDT", "")
|
|
spf = f"{s['profit_factor']:.2f}" if s["profit_factor"] != float("inf") else "INF"
|
|
sym_parts.append(f"{short}: PF {spf} ({s['total_trades']}건)")
|
|
lines.append(f" {' | '.join(sym_parts)}")
|
|
|
|
# 실전 트레이드
|
|
lt = data["live_trades"]
|
|
if lt["count"] > 0:
|
|
lines += [
|
|
"",
|
|
f"[실전 트레이드 (이번 주)]",
|
|
f" 거래: {lt['count']}건 | 순수익: {lt['net_pnl']:+.2f} USDT | 승률: {lt['win_rate']:.1f}%",
|
|
]
|
|
|
|
# 추이
|
|
trend = data["trend"]
|
|
if trend["pf"]:
|
|
pf_trend = " → ".join(f"{v:.2f}" for v in trend["pf"])
|
|
warn = " ⚠ 하락 추세" if trend["pf_declining_3w"] else ""
|
|
lines += ["", f"[추이 (최근 {len(trend['pf'])}주)]", f" PF: {pf_trend}{warn}"]
|
|
if trend["win_rate"]:
|
|
wr_trend = " → ".join(f"{v:.0f}%" for v in trend["win_rate"])
|
|
lines.append(f" 승률: {wr_trend}")
|
|
if trend["mdd"]:
|
|
mdd_trend = " → ".join(f"{v:.0f}%" for v in trend["mdd"])
|
|
lines.append(f" MDD: {mdd_trend}")
|
|
|
|
# ML 재도전 체크리스트
|
|
ml = data["ml_trigger"]
|
|
cond = ml["conditions"]
|
|
lines += [
|
|
"",
|
|
f"[ML 재도전 체크리스트]",
|
|
f" {'✅' if cond['cumulative_trades_enough'] else '☐'} 누적 트레이드 ≥ {ml['threshold']}건: {ml['cumulative_trades']}/{ml['threshold']}",
|
|
f" {'✅' if cond['pf_below_1'] else '☐'} PF < 1.0: {'예' if cond['pf_below_1'] else '아니오'} (현재 {pf_str})",
|
|
f" {'✅' if cond['pf_declining_3w'] else '☐'} PF 3주 연속 하락: {'예 ⚠' if cond['pf_declining_3w'] else '아니오'}",
|
|
]
|
|
if ml["recommend"]:
|
|
lines.append(f" → 🔔 ML 재학습 권장! ({ml['met_count']}/3 충족)")
|
|
else:
|
|
lines.append(f" → ML 재도전 시점: 아직 아님 ({ml['met_count']}/3 충족)")
|
|
|
|
# 파라미터 스윕
|
|
sweep = data.get("sweep")
|
|
if sweep:
|
|
lines += ["", "[파라미터 스윕 결과]"]
|
|
current_pf_str = pf_str
|
|
lines.append(f" 현재: {_param_str(PROD_PARAMS)} → PF {current_pf_str}")
|
|
for i, alt in enumerate(sweep):
|
|
apf = alt["summary"]["profit_factor"]
|
|
apf_str = f"{apf:.2f}" if apf != float("inf") else "INF"
|
|
diff = apf - pf
|
|
lines.append(f" 대안 {i+1}: {_param_str(alt['params'])} → PF {apf_str} ({diff:+.2f})")
|
|
lines.append("")
|
|
lines.append(" ⚠ 자동 적용되지 않음. 검토 후 승인 필요.")
|
|
elif pf >= 1.0:
|
|
lines += ["", "[파라미터 스윕]", " 현재 파라미터가 최적 — 스윕 불필요"]
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _param_str(p: dict) -> str:
|
|
return (f"SL={p.get('atr_sl_mult', '?')}, TP={p.get('atr_tp_mult', '?')}, "
|
|
f"ADX={p.get('adx_threshold', '?')}, Vol={p.get('volume_multiplier', '?')}")
|
|
|
|
|
|
def send_report(content: str, webhook_url: str | None = None) -> None:
|
|
"""Discord 웹훅으로 리포트를 전송한다."""
|
|
url = webhook_url or os.getenv("DISCORD_WEBHOOK_URL", "")
|
|
if not url:
|
|
logger.warning("DISCORD_WEBHOOK_URL이 설정되지 않아 전송 스킵")
|
|
return
|
|
notifier = DiscordNotifier(url)
|
|
notifier._send(content)
|
|
logger.info("Discord 리포트 전송 완료")
|
|
```
|
|
|
|
**Step 4: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 12 PASS
|
|
|
|
**Step 5: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): Discord 리포트 포맷팅 및 전송 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: 메인 오케스트레이션 + CLI + JSON 저장 추가
|
|
|
|
**Files:**
|
|
- Modify: `scripts/weekly_report.py`
|
|
- Test: `tests/test_weekly_report.py`
|
|
|
|
**Step 1: 실패 테스트 작성**
|
|
|
|
```python
|
|
# tests/test_weekly_report.py (추가)
|
|
def test_generate_report_orchestration(tmp_path):
|
|
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
|
|
from scripts.weekly_report import generate_report
|
|
|
|
mock_bt_result = {
|
|
"summary": {
|
|
"profit_factor": 1.24, "win_rate": 45.0,
|
|
"max_drawdown_pct": 12.0, "total_trades": 88,
|
|
"total_pnl": 379.0, "return_pct": 37.9,
|
|
"avg_win": 20.0, "avg_loss": -10.0,
|
|
"sharpe_ratio": 33.0, "total_fees": 5.0,
|
|
"close_reasons": {},
|
|
},
|
|
"folds": [],
|
|
"trades": [],
|
|
}
|
|
|
|
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
|
|
with patch("scripts.weekly_report.parse_live_trades", return_value=[]):
|
|
with patch("scripts.weekly_report.load_trend", return_value={
|
|
"pf": [1.31], "win_rate": [48.0], "mdd": [9.0], "pf_declining_3w": False,
|
|
}):
|
|
report = generate_report(
|
|
symbols=["XRPUSDT"],
|
|
report_dir=str(tmp_path),
|
|
log_path=str(tmp_path / "bot.log"),
|
|
report_date=date(2026, 3, 7),
|
|
)
|
|
|
|
assert report["date"] == "2026-03-07"
|
|
assert report["backtest"]["summary"]["profit_factor"] == 1.24
|
|
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
|
|
|
|
|
|
def test_save_report_creates_json(tmp_path):
|
|
"""리포트를 JSON으로 저장."""
|
|
from scripts.weekly_report import save_report
|
|
|
|
report = {"date": "2026-03-07", "test": True}
|
|
save_report(report, str(tmp_path))
|
|
|
|
saved = list(tmp_path.glob("report_*.json"))
|
|
assert len(saved) == 1
|
|
loaded = json.loads(saved[0].read_text())
|
|
assert loaded["date"] == "2026-03-07"
|
|
```
|
|
|
|
**Step 2: 테스트 실행하여 실패 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "generate_report or save_report" -v`
|
|
Expected: FAIL
|
|
|
|
**Step 3: 구현 작성**
|
|
|
|
`scripts/weekly_report.py`에 추가:
|
|
|
|
```python
|
|
import numpy as np
|
|
|
|
|
|
def _sanitize(obj):
|
|
"""JSON 직렬화를 위해 numpy/inf 값을 변환."""
|
|
if isinstance(obj, bool):
|
|
return obj
|
|
if isinstance(obj, (np.integer,)):
|
|
return int(obj)
|
|
if isinstance(obj, (np.floating,)):
|
|
return float(obj)
|
|
if isinstance(obj, float) and (obj == float("inf") or obj == float("-inf")):
|
|
return str(obj)
|
|
if isinstance(obj, dict):
|
|
return {k: _sanitize(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_sanitize(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
def save_report(report: dict, report_dir: str) -> Path:
|
|
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
|
|
rdir = Path(report_dir)
|
|
rdir.mkdir(parents=True, exist_ok=True)
|
|
path = rdir / f"report_{report['date']}.json"
|
|
with open(path, "w") as f:
|
|
json.dump(_sanitize(report), f, indent=2, ensure_ascii=False)
|
|
logger.info(f"리포트 저장: {path}")
|
|
return path
|
|
|
|
|
|
def generate_report(
|
|
symbols: list[str],
|
|
report_dir: str = str(WEEKLY_DIR),
|
|
log_path: str = "logs/bot.log",
|
|
report_date: date | None = None,
|
|
) -> dict:
|
|
"""전체 주간 리포트를 생성한다."""
|
|
today = report_date or date.today()
|
|
|
|
# 1) Walk-Forward 백테스트
|
|
logger.info("백테스트 실행 중...")
|
|
bt_results = {}
|
|
combined_trades = 0
|
|
combined_pnl = 0.0
|
|
combined_gp = 0.0
|
|
combined_gl = 0.0
|
|
|
|
for sym in symbols:
|
|
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
|
bt_results[sym] = result["summary"]
|
|
s = result["summary"]
|
|
n = s["total_trades"]
|
|
combined_trades += n
|
|
combined_pnl += s["total_pnl"]
|
|
if n > 0:
|
|
wr = s["win_rate"] / 100.0
|
|
n_wins = round(wr * n)
|
|
n_losses = n - n_wins
|
|
combined_gp += s["avg_win"] * n_wins if n_wins > 0 else 0
|
|
combined_gl += abs(s["avg_loss"]) * n_losses if n_losses > 0 else 0
|
|
|
|
combined_pf = combined_gp / combined_gl if combined_gl > 0 else float("inf")
|
|
combined_wr = (
|
|
sum(s["win_rate"] * s["total_trades"] for s in bt_results.values())
|
|
/ combined_trades if combined_trades > 0 else 0
|
|
)
|
|
combined_mdd = max((s["max_drawdown_pct"] for s in bt_results.values()), default=0)
|
|
|
|
backtest_summary = {
|
|
"profit_factor": round(combined_pf, 2),
|
|
"win_rate": round(combined_wr, 1),
|
|
"max_drawdown_pct": round(combined_mdd, 1),
|
|
"total_trades": combined_trades,
|
|
"total_pnl": round(combined_pnl, 2),
|
|
}
|
|
|
|
# 2) 실전 트레이드 파싱
|
|
logger.info("실전 로그 파싱 중...")
|
|
live_trades = parse_live_trades(log_path, days=7)
|
|
live_wins = sum(1 for t in live_trades if t.get("net_pnl", 0) > 0)
|
|
live_pnl = sum(t.get("net_pnl", 0) for t in live_trades)
|
|
live_summary = {
|
|
"count": len(live_trades),
|
|
"net_pnl": round(live_pnl, 2),
|
|
"win_rate": round(live_wins / len(live_trades) * 100, 1) if live_trades else 0,
|
|
}
|
|
|
|
# 3) 추이 로드
|
|
trend = load_trend(report_dir)
|
|
|
|
# 4) 누적 트레이드 수 계산
|
|
cumulative = combined_trades
|
|
for rpath in sorted(Path(report_dir).glob("report_*.json")) if Path(report_dir).exists() else []:
|
|
try:
|
|
prev = json.loads(rpath.read_text())
|
|
cumulative += prev.get("live_trades", {}).get("count", 0)
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
cumulative += len(live_trades)
|
|
|
|
# 5) ML 트리거 체크
|
|
ml_trigger = check_ml_trigger(
|
|
cumulative_trades=cumulative,
|
|
current_pf=combined_pf,
|
|
pf_declining_3w=trend["pf_declining_3w"],
|
|
)
|
|
|
|
# 6) PF < 1.0이면 스윕 실행
|
|
sweep = None
|
|
if combined_pf < 1.0:
|
|
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
|
|
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
|
|
|
|
return {
|
|
"date": today.isoformat(),
|
|
"backtest": {"summary": backtest_summary, "per_symbol": bt_results},
|
|
"live_trades": live_summary,
|
|
"trend": trend,
|
|
"ml_trigger": ml_trigger,
|
|
"sweep": sweep,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="주간 전략 리포트")
|
|
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
|
|
parser.add_argument("--date", type=str, help="리포트 날짜 (YYYY-MM-DD)")
|
|
args = parser.parse_args()
|
|
|
|
report_date = date.fromisoformat(args.date) if args.date else date.today()
|
|
|
|
# 1) 데이터 수집
|
|
if not args.skip_fetch:
|
|
fetch_latest_data(SYMBOLS)
|
|
|
|
# 2) 리포트 생성
|
|
report = generate_report(
|
|
symbols=SYMBOLS,
|
|
report_date=report_date,
|
|
)
|
|
|
|
# 3) 저장
|
|
save_report(report, str(WEEKLY_DIR))
|
|
|
|
# 4) Discord 전송
|
|
text = format_report(report)
|
|
print(text)
|
|
send_report(text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
**Step 4: 테스트 실행하여 통과 확인**
|
|
|
|
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
|
Expected: 14 PASS
|
|
|
|
**Step 5: 기존 테스트 스위트 실행하여 회귀 없음 확인**
|
|
|
|
Run: `source .venv/bin/activate && bash scripts/run_tests.sh`
|
|
Expected: 121+ 기존 통과 + 14 신규 = 135+ 통과
|
|
|
|
**Step 6: 커밋**
|
|
|
|
```bash
|
|
git add scripts/weekly_report.py tests/test_weekly_report.py
|
|
git commit -m "feat(weekly-report): 메인 오케스트레이션, CLI, JSON 저장 추가"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: 수동 스모크 테스트 + 크론탭 가이드
|
|
|
|
**Files:**
|
|
- 신규 파일 없음
|
|
|
|
**Step 1: 드라이 런 (데이터 수집 스킵, Discord 스킵)**
|
|
|
|
Run:
|
|
```bash
|
|
source .venv/bin/activate && python scripts/weekly_report.py --skip-fetch --date 2026-03-07
|
|
```
|
|
|
|
Expected: 리포트가 터미널에 출력되고 `results/weekly/report_2026-03-07.json` 저장됨.
|
|
|
|
**Step 2: 저장된 JSON 확인**
|
|
|
|
Run: `cat results/weekly/report_2026-03-07.json | python -m json.tool | head -30`
|
|
Expected: date, backtest, live_trades, trend, ml_trigger 키가 포함된 유효한 JSON
|
|
|
|
**Step 3: 최종 상태 커밋**
|
|
|
|
```bash
|
|
git add results/weekly/.gitkeep
|
|
git commit -m "chore: results/weekly 디렉토리 추가"
|
|
```
|
|
|
|
**Step 4: 크론탭 설정 문서화**
|
|
|
|
프로덕션 서버에서:
|
|
```bash
|
|
# 매주 일요일 새벽 3시 (KST = UTC+9 → UTC 18:00 토요일)
|
|
crontab -e
|
|
# 추가:
|
|
0 18 * * 6 cd /app && python scripts/weekly_report.py >> logs/cron.log 2>&1
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: CLAUDE.md 플랜 히스토리 업데이트
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md`
|
|
|
|
**Step 1: 히스토리 테이블에 플랜 항목 추가**
|
|
|
|
플랜 히스토리 테이블에 추가:
|
|
```
|
|
| 2026-03-07 | `weekly-report` (plan) | Completed |
|
|
```
|
|
|
|
**Step 2: Common Commands 섹션에 주간 리포트 명령어 추가**
|
|
|
|
```bash
|
|
# 주간 전략 리포트 (수동)
|
|
python scripts/weekly_report.py --skip-fetch
|
|
|
|
# 주간 리포트 (데이터 새로고침 포함)
|
|
python scripts/weekly_report.py
|
|
```
|
|
|
|
**Step 3: 커밋**
|
|
|
|
```bash
|
|
git add CLAUDE.md
|
|
git commit -m "docs: 주간 리포트를 플랜 히스토리 및 명령어에 추가"
|
|
```
|