fix(weekly-report): calculate combined metrics directly from trades
The combined summary (PF, MDD, win_rate) was indirectly reconstructed from per-symbol averages using round(win_rate * n), which introduced rounding errors. MDD was max() of individual symbol MDDs, ignoring simultaneous drawdowns across the correlated crypto portfolio. Now computes all combined metrics directly from the trade list: - PF: sum(wins) / sum(losses) from actual trade PnLs - MDD: portfolio equity curve from time-sorted trades - Win rate: direct count from trade PnLs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -386,6 +386,39 @@ def save_report(report: dict, report_dir: str) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_combined_summary(trades: list[dict], initial_balance: float = 1000.0) -> dict:
|
||||||
|
"""개별 트레이드 리스트에서 합산 지표를 직접 계산한다."""
|
||||||
|
if not trades:
|
||||||
|
return {
|
||||||
|
"profit_factor": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0,
|
||||||
|
"total_trades": 0, "total_pnl": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
pnls = [t["net_pnl"] for t in trades]
|
||||||
|
wins = [p for p in pnls if p > 0]
|
||||||
|
losses = [p for p in pnls if p <= 0]
|
||||||
|
|
||||||
|
gross_profit = sum(wins) if wins else 0.0
|
||||||
|
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||||
|
|
||||||
|
# 시간순 정렬 후 포트폴리오 equity curve 기반 MDD
|
||||||
|
sorted_trades = sorted(trades, key=lambda t: t["exit_time"])
|
||||||
|
sorted_pnls = [t["net_pnl"] for t in sorted_trades]
|
||||||
|
cumulative = np.cumsum(sorted_pnls)
|
||||||
|
equity = initial_balance + cumulative
|
||||||
|
peak = np.maximum.accumulate(equity)
|
||||||
|
drawdown = (peak - equity) / peak
|
||||||
|
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||||
|
"win_rate": round(len(wins) / len(trades) * 100, 1),
|
||||||
|
"max_drawdown_pct": round(mdd, 1),
|
||||||
|
"total_trades": len(trades),
|
||||||
|
"total_pnl": round(sum(pnls), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_report(
|
def generate_report(
|
||||||
symbols: list[str],
|
symbols: list[str],
|
||||||
report_dir: str = str(WEEKLY_DIR),
|
report_dir: str = str(WEEKLY_DIR),
|
||||||
@@ -400,40 +433,14 @@ def generate_report(
|
|||||||
logger.info("백테스트 실행 중...")
|
logger.info("백테스트 실행 중...")
|
||||||
bt_results = {}
|
bt_results = {}
|
||||||
all_bt_trades = []
|
all_bt_trades = []
|
||||||
combined_trades = 0
|
|
||||||
combined_pnl = 0.0
|
|
||||||
combined_gp = 0.0
|
|
||||||
combined_gl = 0.0
|
|
||||||
|
|
||||||
for sym in symbols:
|
for sym in symbols:
|
||||||
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
||||||
bt_results[sym] = result["summary"]
|
bt_results[sym] = result["summary"]
|
||||||
all_bt_trades.extend(result.get("trades", []))
|
all_bt_trades.extend(result.get("trades", []))
|
||||||
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 = (
|
backtest_summary = _calc_combined_summary(all_bt_trades)
|
||||||
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) 운영 대시보드 API에서 실전 트레이드 조회
|
# 2) 운영 대시보드 API에서 실전 트레이드 조회
|
||||||
logger.info(f"대시보드 API에서 실전 트레이드 조회 중... ({dashboard_url})")
|
logger.info(f"대시보드 API에서 실전 트레이드 조회 중... ({dashboard_url})")
|
||||||
@@ -464,15 +471,16 @@ def generate_report(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# 5) ML 트리거 체크
|
# 5) ML 트리거 체크
|
||||||
|
current_pf = backtest_summary["profit_factor"]
|
||||||
ml_trigger = check_ml_trigger(
|
ml_trigger = check_ml_trigger(
|
||||||
cumulative_trades=cumulative,
|
cumulative_trades=cumulative,
|
||||||
current_pf=combined_pf,
|
current_pf=current_pf,
|
||||||
pf_declining_3w=trend["pf_declining_3w"],
|
pf_declining_3w=trend["pf_declining_3w"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6) PF < 1.0이면 스윕 실행
|
# 6) PF < 1.0이면 스윕 실행
|
||||||
sweep = None
|
sweep = None
|
||||||
if combined_pf < 1.0:
|
if current_pf < 1.0:
|
||||||
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
|
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
|
||||||
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
|
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
|
||||||
|
|
||||||
|
|||||||
@@ -262,16 +262,22 @@ def test_generate_report_orchestration(tmp_path):
|
|||||||
from scripts.weekly_report import generate_report
|
from scripts.weekly_report import generate_report
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# 합산 지표는 개별 트레이드에서 직접 계산되므로 mock에 트레이드 포함
|
||||||
|
mock_trades = [
|
||||||
|
{"net_pnl": 20.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-10 12:00:00"},
|
||||||
|
{"net_pnl": 15.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-11 12:00:00"},
|
||||||
|
{"net_pnl": -10.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-12 12:00:00"},
|
||||||
|
]
|
||||||
mock_bt_result = {
|
mock_bt_result = {
|
||||||
"summary": {
|
"summary": {
|
||||||
"profit_factor": 1.24, "win_rate": 45.0,
|
"profit_factor": 1.24, "win_rate": 45.0,
|
||||||
"max_drawdown_pct": 12.0, "total_trades": 88,
|
"max_drawdown_pct": 12.0, "total_trades": 3,
|
||||||
"total_pnl": 379.0, "return_pct": 37.9,
|
"total_pnl": 25.0, "return_pct": 2.5,
|
||||||
"avg_win": 20.0, "avg_loss": -10.0,
|
"avg_win": 17.5, "avg_loss": -10.0,
|
||||||
"sharpe_ratio": 33.0, "total_fees": 5.0,
|
"sharpe_ratio": 33.0, "total_fees": 6.0,
|
||||||
"close_reasons": {},
|
"close_reasons": {},
|
||||||
},
|
},
|
||||||
"folds": [], "trades": [],
|
"folds": [], "trades": mock_trades,
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
|
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
|
||||||
@@ -287,8 +293,9 @@ def test_generate_report_orchestration(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert report["date"] == "2026-03-07"
|
assert report["date"] == "2026-03-07"
|
||||||
# PF는 avg_win/avg_loss에서 재계산됨 (GP=40*20=800, GL=48*10=480 → 1.67)
|
# PF는 개별 트레이드에서 직접 계산: GP=35, GL=10 → 3.5
|
||||||
assert report["backtest"]["summary"]["profit_factor"] == 1.67
|
assert report["backtest"]["summary"]["profit_factor"] == 3.5
|
||||||
|
assert report["backtest"]["summary"]["total_trades"] == 3
|
||||||
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
|
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user