Files
Bithumb/deepcoin/ops/portfolio_report.py
2026-06-03 16:46:17 +01:00

436 lines
13 KiB
Python

"""
운영 포트폴리오 스냅샷·최근 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}")