Compare commits
9 Commits
771b357f28
...
6a6740d708
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6740d708 | ||
|
|
f47ad26156 | ||
|
|
1b1542d51f | ||
|
|
90d99a1662 | ||
|
|
58596785aa | ||
|
|
3b0335f57e | ||
|
|
35177bf345 | ||
|
|
9011344aab | ||
|
|
2e788c0d0f |
@@ -36,6 +36,12 @@ bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
|||||||
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
||||||
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
# Weekly strategy report (manual, skip data fetch)
|
||||||
|
python scripts/weekly_report.py --skip-fetch
|
||||||
|
|
||||||
|
# Weekly report with data refresh
|
||||||
|
python scripts/weekly_report.py
|
||||||
|
|
||||||
# Fetch historical data (single symbol with auto correlation)
|
# Fetch historical data (single symbol with auto correlation)
|
||||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||||
|
|
||||||
@@ -131,3 +137,4 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
|||||||
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
||||||
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
||||||
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
|
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
|
||||||
|
| 2026-03-07 | `weekly-report` (plan) | Completed |
|
||||||
|
|||||||
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
0
results/weekly/.gitkeep
Normal file
0
results/weekly/.gitkeep
Normal file
492
scripts/weekly_report.py
Normal file
492
scripts/weekly_report.py
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
#!/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 os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||||
|
from src.notifier import DiscordNotifier
|
||||||
|
|
||||||
|
|
||||||
|
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 로그 파싱 패턴 ────────────────────────────────────────────────
|
||||||
|
_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] = {}
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── 추이 추적 ────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── ML 재학습 트리거 & 성능 저하 스윕 ─────────────────────────────
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Discord 리포트 포맷 & 전송 ─────────────────────────────────────
|
||||||
|
|
||||||
|
_EMOJI_CHART = "\U0001F4CA"
|
||||||
|
_EMOJI_ALERT = "\U0001F6A8"
|
||||||
|
_EMOJI_BELL = "\U0001F514"
|
||||||
|
_CHECK = "\u2705"
|
||||||
|
_UNCHECK = "\u2610"
|
||||||
|
_WARN = "\u26A0"
|
||||||
|
_ARROW = "\u2192"
|
||||||
|
|
||||||
|
|
||||||
|
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 = f" {_EMOJI_ALERT} 손실 구간"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"{_EMOJI_CHART} 주간 전략 리포트 ({d})",
|
||||||
|
"",
|
||||||
|
"[현재 성능 — 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" 거래: {lt['count']}건 | 순수익: {lt['net_pnl']:+.2f} USDT | 승률: {lt['win_rate']:.1f}%",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 추이
|
||||||
|
trend = data["trend"]
|
||||||
|
if trend["pf"]:
|
||||||
|
pf_trend = f" {_ARROW} ".join(f"{v:.2f}" for v in trend["pf"])
|
||||||
|
warn = f" {_WARN} 하락 추세" if trend["pf_declining_3w"] else ""
|
||||||
|
pf_len = len(trend["pf"])
|
||||||
|
lines += ["", f"[추이 (최근 {pf_len}주)]", f" PF: {pf_trend}{warn}"]
|
||||||
|
if trend["win_rate"]:
|
||||||
|
wr_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["win_rate"])
|
||||||
|
lines.append(f" 승률: {wr_trend}")
|
||||||
|
if trend["mdd"]:
|
||||||
|
mdd_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["mdd"])
|
||||||
|
lines.append(f" MDD: {mdd_trend}")
|
||||||
|
|
||||||
|
# ML 재도전 체크리스트
|
||||||
|
ml = data["ml_trigger"]
|
||||||
|
cond = ml["conditions"]
|
||||||
|
threshold = ml["threshold"]
|
||||||
|
cum_trades = ml["cumulative_trades"]
|
||||||
|
|
||||||
|
c1 = _CHECK if cond["cumulative_trades_enough"] else _UNCHECK
|
||||||
|
c2 = _CHECK if cond["pf_below_1"] else _UNCHECK
|
||||||
|
c3 = _CHECK if cond["pf_declining_3w"] else _UNCHECK
|
||||||
|
pf_below_label = "예" if cond["pf_below_1"] else "아니오"
|
||||||
|
pf_dec_label = f"예 {_WARN}" if cond["pf_declining_3w"] else "아니오"
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"[ML 재도전 체크리스트]",
|
||||||
|
f" {c1} 누적 트레이드 \u2265 {threshold}건: {cum_trades}/{threshold}",
|
||||||
|
f" {c2} PF < 1.0: {pf_below_label} (현재 {pf_str})",
|
||||||
|
f" {c3} PF 3주 연속 하락: {pf_dec_label}",
|
||||||
|
]
|
||||||
|
met_count = ml["met_count"]
|
||||||
|
if ml["recommend"]:
|
||||||
|
lines.append(f" {_ARROW} {_EMOJI_BELL} ML 재학습 권장! ({met_count}/3 충족)")
|
||||||
|
else:
|
||||||
|
lines.append(f" {_ARROW} ML 재도전 시점: 아직 아님 ({met_count}/3 충족)")
|
||||||
|
|
||||||
|
# 파라미터 스윕
|
||||||
|
sweep = data.get("sweep")
|
||||||
|
if sweep:
|
||||||
|
lines += ["", "[파라미터 스윕 결과]"]
|
||||||
|
lines.append(f" 현재: {_param_str(PROD_PARAMS)} {_ARROW} PF {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
|
||||||
|
idx = i + 1
|
||||||
|
lines.append(f" 대안 {idx}: {_param_str(alt['params'])} {_ARROW} PF {apf_str} ({diff:+.2f})")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" {_WARN} 자동 적용되지 않음. 검토 후 승인 필요.")
|
||||||
|
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 리포트 전송 완료")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(obj):
|
||||||
|
"""JSON 직렬화를 위해 numpy/inf 값을 변환."""
|
||||||
|
if isinstance(obj, (bool, np.bool_)):
|
||||||
|
return bool(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_list = parse_live_trades(log_path, days=7)
|
||||||
|
live_wins = sum(1 for t in live_trades_list if t.get("net_pnl", 0) > 0)
|
||||||
|
live_pnl = sum(t.get("net_pnl", 0) for t in live_trades_list)
|
||||||
|
live_summary = {
|
||||||
|
"count": len(live_trades_list),
|
||||||
|
"net_pnl": round(live_pnl, 2),
|
||||||
|
"win_rate": round(live_wins / len(live_trades_list) * 100, 1) if live_trades_list else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) 추이 로드
|
||||||
|
trend = load_trend(report_dir)
|
||||||
|
|
||||||
|
# 4) 누적 트레이드 수
|
||||||
|
cumulative = combined_trades + len(live_trades_list)
|
||||||
|
rdir = Path(report_dir)
|
||||||
|
if rdir.exists():
|
||||||
|
for rpath in sorted(rdir.glob("report_*.json")):
|
||||||
|
try:
|
||||||
|
prev = json.loads(rpath.read_text())
|
||||||
|
cumulative += prev.get("live_trades", {}).get("count", 0)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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()
|
||||||
282
tests/test_weekly_report.py
Normal file
282
tests/test_weekly_report.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_trend_reads_previous_reports(tmp_path):
|
||||||
|
"""이전 주간 리포트를 읽어 PF/승률/MDD 추이를 반환."""
|
||||||
|
from scripts.weekly_report import load_trend
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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_returns_top_n():
|
||||||
|
"""스윕을 실행하고 PF 상위 N개 대안을 반환."""
|
||||||
|
from scripts.weekly_report import run_degradation_sweep
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
fake_summaries = [
|
||||||
|
{"profit_factor": 1.15, "total_trades": 30, "total_pnl": 50, "return_pct": 5,
|
||||||
|
"win_rate": 55, "avg_win": 10, "avg_loss": -8, "max_drawdown_pct": 10,
|
||||||
|
"sharpe_ratio": 2.0, "total_fees": 1, "close_reasons": {}},
|
||||||
|
{"profit_factor": 1.08, "total_trades": 25, "total_pnl": 30, "return_pct": 3,
|
||||||
|
"win_rate": 50, "avg_win": 8, "avg_loss": -7, "max_drawdown_pct": 12,
|
||||||
|
"sharpe_ratio": 1.5, "total_fees": 1, "close_reasons": {}},
|
||||||
|
{"profit_factor": 0.95, "total_trades": 20, "total_pnl": -10, "return_pct": -1,
|
||||||
|
"win_rate": 40, "avg_win": 6, "avg_loss": -9, "max_drawdown_pct": 15,
|
||||||
|
"sharpe_ratio": 0.5, "total_fees": 1, "close_reasons": {}},
|
||||||
|
]
|
||||||
|
fake_combos = [
|
||||||
|
{"atr_sl_mult": 1.5}, {"atr_sl_mult": 1.0}, {"atr_sl_mult": 2.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("scripts.weekly_report.run_single_backtest") as mock_bt:
|
||||||
|
mock_bt.side_effect = fake_summaries
|
||||||
|
with patch("scripts.weekly_report.generate_combinations", return_value=fake_combos):
|
||||||
|
alternatives = run_degradation_sweep(
|
||||||
|
symbols=["XRPUSDT"], train_months=3, test_months=1, top_n=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(alternatives) <= 3
|
||||||
|
assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"]
|
||||||
|
|
||||||
|
|
||||||
|
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 "\uc8fc\uac04 \uc804\ub7b5 \ub9ac\ud3ec\ud2b8" in text
|
||||||
|
assert "1.24" in text
|
||||||
|
assert "XRPUSDT" in text or "XRP" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_report_degraded():
|
||||||
|
"""PF < 1.0일 때 스윕 결과 + ML 권장이 포함되는지 확인."""
|
||||||
|
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
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_report_orchestration(tmp_path):
|
||||||
|
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
|
||||||
|
from scripts.weekly_report import generate_report
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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"
|
||||||
|
# PF는 avg_win/avg_loss에서 재계산됨 (GP=40*20=800, GL=48*10=480 → 1.67)
|
||||||
|
assert report["backtest"]["summary"]["profit_factor"] == 1.67
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user