Compare commits

...

9 Commits

Author SHA1 Message Date
21in7
6a6740d708 docs: update CLAUDE.md with weekly-report commands and plan status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:53:13 +09:00
21in7
f47ad26156 fix(weekly-report): handle numpy.bool_ in JSON serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:52:51 +09:00
21in7
1b1542d51f feat(weekly-report): add main orchestration, CLI, JSON save
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:49:16 +09:00
21in7
90d99a1662 feat(weekly-report): add Discord report formatting and sending
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:46:03 +09:00
21in7
58596785aa feat(weekly-report): add ML trigger check and degradation sweep
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:42:53 +09:00
21in7
3b0335f57e feat(weekly-report): add trend tracking from previous reports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:40:39 +09:00
21in7
35177bf345 feat(weekly-report): add live trade log parser
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:39:03 +09:00
21in7
9011344aab feat(weekly-report): add data fetch and WF backtest core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:34:48 +09:00
21in7
2e788c0d0f docs: add weekly strategy report implementation plan
8-task plan covering: data fetch, WF backtest, log parsing, trend tracking,
ML re-trigger check, degradation sweep, Discord formatting, CLI orchestration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:28:28 +09:00
5 changed files with 1852 additions and 0 deletions

View File

@@ -36,6 +36,12 @@ bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
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)
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-06 | `multi-symbol-dashboard` (design + plan) | Completed |
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
| 2026-03-07 | `weekly-report` (plan) | Completed |

File diff suppressed because it is too large Load Diff

0
results/weekly/.gitkeep Normal file
View File

492
scripts/weekly_report.py Normal file
View 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
View 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"