#!/usr/bin/env python3 """ Phase C dry-run 종료 후 모의 수익률(참고) 집계. - 입력: data/ops/paper_fires.jsonl (06 dry-run 발화 로그) - 출력: docs/05_ops/phase_c_paper_report.json + 콘솔 요약 주의: 실계좌 수익이 아님. 발화가 N봉 후 가격으로 계산한 forward % 합산(참고). hybrid 복리 PnL은 simulation_report.html 과 다릅니다. """ from __future__ import annotations import argparse import json import runpy from datetime import datetime from pathlib import Path import numpy as np import pandas as pd runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py")) from config import ( # noqa: E402 MATCH_FORWARD_BARS, MATCH_PRIMARY_INTERVAL, SYMBOL, TRADING_FEE_RATE, ) from deepcoin.matching.label_outcomes import _forward_ret_vectorized # noqa: E402 from deepcoin.ops.monitor import Monitor # noqa: E402 from deepcoin.ops.paper_portfolio import PaperPortfolio # noqa: E402 from deepcoin.paths import ( # noqa: E402 PAPER_FIRES_LOG, PAPER_WEEKLY_REPORT_JSON, PHASE_C_DAILY_DIR, ) _FEE_PCT = TRADING_FEE_RATE * 2 * 100 def load_paper_fires(path: Path) -> pd.DataFrame: """paper_fires.jsonl → DataFrame.""" if not path.is_file(): return pd.DataFrame() rows = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line: rows.append(json.loads(line)) if not rows: return pd.DataFrame() return pd.DataFrame(rows) def attach_forward_returns(fires: pd.DataFrame, close_df: pd.DataFrame) -> pd.DataFrame: """ would_trade=True 발화에 MATCH_FORWARD_BARS 기준 forward 수익률(%) 부여. Args: fires: paper 발화. close_df: 3분 종가 (datetime index). Returns: forward_ret_pct 컬럼 추가. """ if fires.empty or close_df.empty: fires["forward_ret_pct"] = np.nan return fires close_df = close_df.sort_index() if not isinstance(close_df.index, pd.DatetimeIndex): close_df.index = pd.to_datetime(close_df.index) close_ts_ns = close_df.index.astype(np.int64).values close_px = close_df["Close"].astype(float).values sub = fires[fires["would_trade"] == True].copy() # noqa: E712 if sub.empty: fires["forward_ret_pct"] = np.nan return fires sig = pd.to_datetime(sub["signal_dt"]) fire_ns = sig.astype(np.int64).values c0 = sub["close"].astype(float).values side = sub["side"].astype(str).values ret, valid = _forward_ret_vectorized( fire_ns, c0, close_ts_ns, close_px, side, MATCH_FORWARD_BARS, _FEE_PCT ) fires = fires.copy() fires["forward_ret_pct"] = np.nan idx = sub.index fires.loc[idx, "forward_ret_pct"] = np.where(valid, ret, np.nan) return fires def summarize(fires: pd.DataFrame, *, report_kind: str = "daily") -> dict: """ 집계 dict. Args: fires: forward_ret_pct 포함 발화 DataFrame. report_kind: daily | final. Returns: JSON 직렬화 가능 dict. """ traded = fires[fires["would_trade"] == True] # noqa: E712 with_ret = traded[traded["forward_ret_pct"].notna()] out: dict = { "generated_at": datetime.now().isoformat(timespec="seconds"), "report_kind": report_kind, "symbol": SYMBOL, "forward_bars": MATCH_FORWARD_BARS, "fee_round_trip_pct": _FEE_PCT, "total_signals": int(len(fires)), "would_trade_count": int(len(traded)), "skipped_count": int(len(fires) - len(traded)), "labeled_count": int(len(with_ret)), "note": ( "forward %는 발화별 참고 지표. " "총보유금액(equity)은 paper_portfolio 모의 체결 기준." ), } if not fires.empty and "ts" in fires.columns: out["log_from"] = str(fires["ts"].min()) out["log_to"] = str(fires["ts"].max()) buy_n = int((traded["side"] == "buy").sum()) if not traded.empty else 0 sell_n = int((traded["side"] == "sell").sum()) if not traded.empty else 0 out["buy_fires"] = buy_n out["sell_fires"] = sell_n if not with_ret.empty: out["mean_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].mean()), 4) out["sum_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].sum()), 4) by_side = ( with_ret.groupby("side")["forward_ret_pct"] .agg(["count", "mean", "sum"]) .round(4) ) out["by_side"] = {k: v.to_dict() for k, v in by_side.iterrows()} by_rule = ( traded.groupby("rule_id") .size() .to_dict() if not traded.empty else {} ) out["fires_by_rule"] = by_rule return out def build_phase_c_report( fires_path: Path | None = None, *, report_kind: str = "daily", ) -> tuple[dict, pd.DataFrame]: """ paper_fires 로드 → forward % → 리포트 dict. Args: fires_path: jsonl 경로 (기본 PAPER_FIRES_LOG). report_kind: daily | final. Returns: (report, fires_with_returns) — 발화 없으면 ({}, empty DataFrame). """ path = fires_path or PAPER_FIRES_LOG fires = load_paper_fires(path) if fires.empty: return {}, fires mon = Monitor(cooldown_file=None) df = mon.read_candles_from_db(SYMBOL, MATCH_PRIMARY_INTERVAL, max_rows=50000) if df.empty: df = mon.get_coin_some_data(SYMBOL, MATCH_PRIMARY_INTERVAL) if not isinstance(df.index, pd.DatetimeIndex): df = df.set_index(pd.to_datetime(df["datetime"])) fires = attach_forward_returns(fires, df) report = summarize(fires, report_kind=report_kind) mark = float(df["Close"].iloc[-1]) if not df.empty and "Close" in df.columns else 0.0 paper = PaperPortfolio.load() report["paper_portfolio"] = paper.summary(mark) return report, fires def format_report_text(report: dict) -> str: """사람이 읽기 쉬운 요약 텍스트.""" kind = report.get("report_kind", "daily") title = "Phase C 최종 보고" if kind == "final" else "Phase C 중간 보고" lines = [ f"=== {title} ({report.get('generated_at', '')}) ===", f"심볼: {report.get('symbol', '')}", ] pf = report.get("paper_portfolio") or {} if pf: lines.extend( [ "--- 모의 계좌 (dry-run, 빗썸 잔고 미사용) ---", f"초기 자금: ₩{pf.get('initial_cash_krw', 0):,.0f}", f"현금: ₩{pf.get('cash_krw', 0):,.0f} · " f"보유 {pf.get('qty', 0):.4f} {report.get('symbol', '')} " f"(평가단가 ₩{pf.get('mark_price', 0):,.0f})", f"코인 평가: ₩{pf.get('coin_value_krw', 0):,.0f}", f"총보유금액: ₩{pf.get('equity_krw', 0):,.0f} " f"(손익 ₩{pf.get('pnl_krw', 0):+,.0f} / {pf.get('pnl_pct', 0):+.2f}%)", ] ) lines.append( f"발화 합계: {report.get('total_signals', 0)} " f"(체결 {report.get('would_trade_count', 0)}, " f"매수 {report.get('buy_fires', 0)} / 매도 {report.get('sell_fires', 0)})" ) if "log_from" in report: lines.append(f"로그 구간: {report['log_from']} ~ {report['log_to']}") if "sum_forward_ret_pct" in report: lines.append( f"모의 forward 합산: {report['sum_forward_ret_pct']}% " f"(평균 {report['mean_forward_ret_pct']}%, " f"{report.get('forward_bars')}봉 후, 참고용)" ) else: lines.append("모의 forward: 라벨 가능 건 없음 (봉·발화 부족)") lines.append(report.get("note", "")) return "\n".join(lines) def write_report_outputs( report: dict, *, json_path: Path | None = None, text_path: Path | None = None, ) -> None: """JSON·텍스트 리포트 저장.""" if json_path: json_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8", ) if text_path: text_path.parent.mkdir(parents=True, exist_ok=True) text_path.write_text(format_report_text(report), encoding="utf-8") def append_verification_log(report: dict, verification_md: Path) -> None: """live_verification 일별 표 해당 날짜 행 갱신.""" if not verification_md.is_file(): return text = verification_md.read_text(encoding="utf-8") iso = report.get("generated_at", "")[:10] try: y, m, d = iso.split("-") day_label = f"{int(m)}/{int(d)}" except ValueError: return buy = report.get("buy_fires", 0) sell = report.get("sell_fires", 0) pf = report.get("paper_portfolio") or {} equity = pf.get("equity_krw", "-") pnl_pct = pf.get("pnl_pct", "-") kind = report.get("report_kind", "daily") memo = "C 최종" if kind == "final" else "중간보고" row = ( f"| {day_label} | Y | - | Y | {buy} | {sell} | " f"총₩{equity} ({pnl_pct}%) {memo} |" ) marker = "### 일별 기록" if marker not in text: return head, table = text.split(marker, 1) lines = table.splitlines() new_lines: list[str] = [] replaced = False for line in lines: if line.startswith(f"| {day_label} |"): new_lines.append(row) replaced = True else: new_lines.append(line) if not replaced: new_lines.append(row) verification_md.write_text(head + marker + "\n".join(new_lines), encoding="utf-8") def run_report( *, report_kind: str = "daily", stamp: str | None = None, update_verification: bool = True, ) -> dict: """ 리포트 생성·저장·콘솔 출력. Args: report_kind: daily | final. stamp: 파일명용 타임스탬프 (기본 now). update_verification: live_verification md 갱신 여부. Returns: report dict (빈 dict 가능). """ report, fires = build_phase_c_report(report_kind=report_kind) if not report: print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}") print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도") return {} stamp = stamp or datetime.now().strftime("%Y%m%d_%H%M") daily_dir = PHASE_C_DAILY_DIR write_report_outputs( report, json_path=daily_dir / f"report_{stamp}_{report_kind}.json", text_path=daily_dir / f"report_{stamp}_{report_kind}.txt", ) write_report_outputs(report, json_path=PAPER_WEEKLY_REPORT_JSON) if update_verification: append_verification_log( report, Path(__file__).resolve().parents[1] / "docs/05_ops/live_verification_20260601.md", ) print(format_report_text(report)) print(f"[07] JSON: {PAPER_WEEKLY_REPORT_JSON}") print(f"[07] 일별: {daily_dir}/report_{stamp}_{report_kind}.*") return report def main() -> None: """CLI: paper_fires → forward % → 리포트 저장.""" parser = argparse.ArgumentParser(description="Phase C 모의 forward % 집계") parser.add_argument( "--kind", choices=("daily", "final"), default="daily", help="daily=중간, final=금요일 최종", ) parser.add_argument("--no-verification-md", action="store_true") args = parser.parse_args() run_report( report_kind=args.kind, update_verification=not args.no_verification_md, ) if __name__ == "__main__": main()