""" 운영 포트폴리오 스냅샷·최근 24시간 수익률 텔레그램 리포트. """ from __future__ import annotations import json import logging from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from zoneinfo import ZoneInfo from config import ( COIN_NAME, COIN_TELEGRAM_BOT_TOKEN, COIN_TELEGRAM_CHAT_ID, DAILY_PNL_REPORT_ENABLED, DAILY_PNL_REPORT_HOUR, DAILY_PNL_REPORT_MINUTE, DAILY_PNL_REPORT_TZ, DAILY_PNL_SNAPSHOT_ON_LIVE, DAILY_PNL_SNAPSHOT_RETENTION_DAYS, ENTRY_INTERVAL, GT_INITIAL_CASH_KRW, SYMBOL, ) from deepcoin.ops.monitor import Monitor from deepcoin.paths import LIVE_TRADES_LOG, PORTFOLIO_SNAPSHOTS_LOG, ensure_dirs logger = logging.getLogger(__name__) ROLLING_HOURS = 24 MAX_ANCHOR_SKEW_HOURS = 6 def _report_tz() -> ZoneInfo: """ 리포트 기준 타임존. Returns: ZoneInfo (잘못된 이름이면 UTC). """ try: return ZoneInfo(DAILY_PNL_REPORT_TZ) except Exception: logger.warning("DAILY_PNL_REPORT_TZ=%s invalid, using UTC", DAILY_PNL_REPORT_TZ) return ZoneInfo("UTC") def _parse_ts(value: str) -> datetime: """ISO 시각 → timezone-aware datetime.""" dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def fetch_mark_price(monitor: Monitor, symbol: str = SYMBOL) -> float: """ 최근 종가(3분봉)를 조회합니다. Args: monitor: Monitor 인스턴스. symbol: 통화 코드. Returns: 종가. 실패 시 0. """ try: data = monitor.get_coin_data(symbol, interval=ENTRY_INTERVAL) if data is None or data.empty: return 0.0 return float(data["Close"].iloc[-1]) except Exception as exc: logger.warning("mark price fetch failed: %s", exc) return 0.0 def portfolio_from_balances( balances: dict[str, dict[str, float]], mark_price: float, *, ts: datetime | None = None, ) -> dict[str, Any]: """ 잔고·시세로 총자산 스냅샷 dict를 만듭니다. Args: balances: load_balances_dict() 결과. mark_price: 코인 평가 단가. ts: 기록 시각 (None이면 now UTC). Returns: 스냅샷 필드 dict. """ when = ts or datetime.now(timezone.utc) krw = float((balances.get("KRW") or {}).get("balance") or 0.0) qty = float((balances.get(SYMBOL) or {}).get("balance") or 0.0) holding_value = qty * mark_price if mark_price > 0 else 0.0 total = krw + holding_value return { "ts": when.isoformat(timespec="seconds"), "symbol": SYMBOL, "krw": round(krw, 0), "coin_qty": qty, "mark_price": mark_price, "holding_value_krw": round(holding_value, 0), "total_asset_krw": round(total, 0), } def fetch_portfolio_snapshot(monitor: Monitor) -> dict[str, Any]: """ API 잔고·시세로 현재 포트폴리오 스냅샷을 조회합니다. Args: monitor: Monitor 인스턴스. Returns: portfolio_from_balances 결과. Raises: RuntimeError: 잔고 API 오류. """ balances = monitor.load_balances_dict() mark = fetch_mark_price(monitor) if mark <= 0: raise RuntimeError("mark price unavailable") return portfolio_from_balances(balances, mark) def append_portfolio_snapshot(snapshot: dict[str, Any]) -> None: """portfolio_snapshots.jsonl에 1행 추가 후 오래된 행 정리.""" ensure_dirs() PORTFOLIO_SNAPSHOTS_LOG.parent.mkdir(parents=True, exist_ok=True) with PORTFOLIO_SNAPSHOTS_LOG.open("a", encoding="utf-8") as f: f.write(json.dumps(snapshot, ensure_ascii=False) + "\n") _prune_snapshots(DAILY_PNL_SNAPSHOT_RETENTION_DAYS) def _prune_snapshots(retention_days: int) -> None: """보관 일수 초과 스냅샷 삭제.""" if not PORTFOLIO_SNAPSHOTS_LOG.is_file(): return cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) kept: list[str] = [] try: for line in PORTFOLIO_SNAPSHOTS_LOG.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue row = json.loads(line) if _parse_ts(row["ts"]) >= cutoff: kept.append(line) except (json.JSONDecodeError, OSError, KeyError) as exc: logger.warning("snapshot prune skipped: %s", exc) return PORTFOLIO_SNAPSHOTS_LOG.write_text( "\n".join(kept) + ("\n" if kept else ""), encoding="utf-8", ) def load_snapshots() -> list[dict[str, Any]]: """스냅샷 jsonl 전체 로드 (시각 오름차순).""" if not PORTFOLIO_SNAPSHOTS_LOG.is_file(): return [] rows: list[dict[str, Any]] = [] for line in PORTFOLIO_SNAPSHOTS_LOG.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: rows.append(json.loads(line)) except json.JSONDecodeError: continue rows.sort(key=lambda r: _parse_ts(r["ts"])) return rows def _nearest_snapshot( snapshots: list[dict[str, Any]], target: datetime, ) -> tuple[dict[str, Any] | None, float]: """ target에 가장 가까운 스냅샷. Returns: (스냅샷, |시차| 시간). """ if not snapshots: return None, 0.0 best = snapshots[0] best_hours = abs((_parse_ts(best["ts"]) - target).total_seconds()) / 3600.0 for row in snapshots[1:]: hours = abs((_parse_ts(row["ts"]) - target).total_seconds()) / 3600.0 if hours < best_hours: best = row best_hours = hours return best, best_hours def count_trades_since(path: Path, since: datetime) -> dict[str, int]: """ live_trades.jsonl에서 구간 내 체결 건수. Returns: {"buy": n, "sell": n, "ok": n}. """ out = {"buy": 0, "sell": 0, "ok": 0} if not path.is_file(): return out for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: row = json.loads(line) except json.JSONDecodeError: continue if not row.get("ok"): continue try: ts = _parse_ts(str(row.get("ts", ""))) except (TypeError, ValueError): continue if ts < since: continue side = str(row.get("side", "")).lower() if side in out: out[side] += 1 out["ok"] += 1 return out def compute_24h_return( current: dict[str, Any], snapshots: list[dict[str, Any]] | None = None, *, baseline_krw: float | None = None, ) -> dict[str, Any]: """ 최근 24시간 총자산 수익률을 계산합니다. Args: current: 현재 스냅샷. snapshots: 과거 스냅샷 목록 (None이면 파일 로드). baseline_krw: 앵커 없을 때 대체 기준(초기 자금). Returns: 리포트 dict (pnl_pct, anchor, trade_counts 등). """ snapshots = snapshots if snapshots is not None else load_snapshots() now = _parse_ts(current["ts"]) target = now - timedelta(hours=ROLLING_HOURS) anchor, skew_h = _nearest_snapshot(snapshots, target) base_asset = float(baseline_krw or GT_INITIAL_CASH_KRW) anchor_ts = None anchor_note = f"기준=초기자금 ₩{base_asset:,.0f} (24h 스냅샷 없음)" if anchor is not None: base_asset = float(anchor["total_asset_krw"]) anchor_ts = anchor["ts"] if skew_h <= MAX_ANCHOR_SKEW_HOURS: anchor_note = f"기준=24h 전 스냅샷 ({anchor_ts}, 시차 {skew_h:.1f}h)" else: anchor_note = ( f"기준=가장 가까운 스냅샷 ({anchor_ts}, 24h 대비 시차 {skew_h:.1f}h)" ) final_asset = float(current["total_asset_krw"]) pnl_krw = final_asset - base_asset pnl_pct = (pnl_krw / base_asset * 100.0) if base_asset > 0 else 0.0 cum_pct = ( (final_asset - GT_INITIAL_CASH_KRW) / GT_INITIAL_CASH_KRW * 100.0 if GT_INITIAL_CASH_KRW > 0 else 0.0 ) trades = count_trades_since(LIVE_TRADES_LOG, target) return { "current": current, "anchor": anchor, "anchor_ts": anchor_ts, "anchor_note": anchor_note, "anchor_skew_hours": skew_h if anchor else None, "base_asset_krw": base_asset, "final_asset_krw": final_asset, "pnl_krw": pnl_krw, "pnl_pct": pnl_pct, "cumulative_pnl_pct": cum_pct, "trade_counts": trades, "window_hours": ROLLING_HOURS, } def build_daily_pnl_message(report: dict[str, Any]) -> str: """ 텔레그램 본문 생성. Args: report: compute_24h_return 결과. Returns: 메시지 문자열. """ cur = report["current"] sign = "+" if report["pnl_pct"] >= 0 else "" cum_sign = "+" if report["cumulative_pnl_pct"] >= 0 else "" tc = report["trade_counts"] tz = _report_tz() now_local = _parse_ts(cur["ts"]).astimezone(tz).strftime("%Y-%m-%d %H:%M") lines = [ f"[DeepCoin 일일 리포트] {COIN_NAME} ({SYMBOL})", f"시각: {now_local} ({DAILY_PNL_REPORT_TZ})", "", f"최근 {report['window_hours']}시간 수익률: {sign}{report['pnl_pct']:.2f}%", f" 손익: {sign}₩{report['pnl_krw']:,.0f}", f" {report['anchor_note']}", f" 24h 전 총자산: ₩{report['base_asset_krw']:,.0f}", f" 현재 총자산: ₩{report['final_asset_krw']:,.0f}", "", f"누적(초기 ₩{GT_INITIAL_CASH_KRW:,} 대비): {cum_sign}{report['cumulative_pnl_pct']:.2f}%", "", "현재 잔고", f" 현금: ₩{float(cur['krw']):,.0f}", f" {SYMBOL}: {float(cur['coin_qty']):.4f} (₩{float(cur['holding_value_krw']):,.0f} @ ₩{float(cur['mark_price']):,.0f})", "", f"24h 체결(성공): 매수 {tc['buy']} · 매도 {tc['sell']} · 합계 {tc['ok']}", "※ 총자산=현금+보유×종가. 미실현 포함.", ] return "\n".join(lines) def maybe_record_portfolio_snapshot(monitor: Monitor) -> None: """ 06 루프용: 설정 시 스냅샷만 기록 (텔레그램 없음). Args: monitor: Monitor/LiveTrader 인스턴스. """ if not DAILY_PNL_SNAPSHOT_ON_LIVE: return try: snap = fetch_portfolio_snapshot(monitor) append_portfolio_snapshot(snap) except Exception as exc: logger.warning("portfolio snapshot skip: %s", exc) def send_daily_pnl_report( monitor: Monitor | None = None, *, send_telegram: bool = True, ) -> dict[str, Any]: """ 스냅샷 저장 → 24h 수익률 계산 → 텔레그램 발송. Args: monitor: None이면 Monitor() 생성. send_telegram: False면 메시지만 반환·콘솔 출력. Returns: compute_24h_return 결과 + message 키. """ if not DAILY_PNL_REPORT_ENABLED: raise RuntimeError("DAILY_PNL_REPORT_ENABLED=0") mon = monitor or Monitor(cooldown_file=None) current = fetch_portfolio_snapshot(mon) append_portfolio_snapshot(current) report = compute_24h_return(current) msg = build_daily_pnl_message(report) report["message"] = msg print(msg) if send_telegram: if not COIN_TELEGRAM_BOT_TOKEN or not COIN_TELEGRAM_CHAT_ID: print("[telegram skip] COIN_TELEGRAM_BOT_TOKEN/CHAT_ID 미설정") else: mon._send_coin_msg(msg) return report def seconds_until_next_report( hour: int | None = None, minute: int | None = None, ) -> float: """ 다음 리포트 시각(로컬 TZ)까지 대기 초. Args: hour: 시 (기본 DAILY_PNL_REPORT_HOUR). minute: 분 (기본 DAILY_PNL_REPORT_MINUTE). Returns: 초 (>= 1). """ tz = _report_tz() h = DAILY_PNL_REPORT_HOUR if hour is None else hour m = DAILY_PNL_REPORT_MINUTE if minute is None else minute now = datetime.now(tz) target = now.replace(hour=h, minute=m, second=0, microsecond=0) if target <= now: target += timedelta(days=1) return max((target - now).total_seconds(), 1.0) def run_schedule_loop(monitor: Monitor | None = None) -> None: """ 매일 지정 시각에 리포트를 발송하는 무한 루프. Args: monitor: 재사용할 Monitor (None이면 매 회 생성). """ tz = _report_tz() print( f"[07] 일일 수익률 텔레그램 · {DAILY_PNL_REPORT_HOUR:02d}:" f"{DAILY_PNL_REPORT_MINUTE:02d} {tz.key} · Ctrl+C 종료" ) while True: wait = seconds_until_next_report() next_at = datetime.now(tz) + timedelta(seconds=wait) print(f"[07] 다음 발송: {next_at:%Y-%m-%d %H:%M:%S} (대기 {wait / 3600:.2f}h)") import time time.sleep(wait) try: send_daily_pnl_report(monitor) except Exception as exc: print(f"[07] 리포트 오류: {exc}")