feat: add daily 24h PnL Telegram report and stop tracking .env
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from deepcoin.ops.hybrid_sim_execution import (
|
||||
sort_hits_sim_order,
|
||||
)
|
||||
from deepcoin.ops.monitor import Monitor
|
||||
from deepcoin.ops.portfolio_report import maybe_record_portfolio_snapshot
|
||||
from deepcoin.paths import (
|
||||
LIVE_SIGNAL_HISTORY_JSON,
|
||||
LIVE_TRADES_LOG,
|
||||
@@ -307,11 +308,13 @@ class LiveTrader(Monitor):
|
||||
)
|
||||
if not rules:
|
||||
print(" monitor_rules 없음 — scripts/04_match_rules.py 실행")
|
||||
maybe_record_portfolio_snapshot(self)
|
||||
return
|
||||
|
||||
fired = evaluate_live_rules(rules, force_refresh=True)
|
||||
if not fired:
|
||||
print(" 발화 없음")
|
||||
maybe_record_portfolio_snapshot(self)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -360,6 +363,7 @@ class LiveTrader(Monitor):
|
||||
)
|
||||
msg += f"\n[체결] {log['message']}"
|
||||
self._send_coin_msg(msg)
|
||||
maybe_record_portfolio_snapshot(self)
|
||||
|
||||
def run_loop(self, sleep_sec: int) -> None:
|
||||
"""상시 루프."""
|
||||
|
||||
@@ -267,11 +267,19 @@ class Monitor(HTS):
|
||||
def load_balances_dict(self) -> dict:
|
||||
"""getBalances() 결과를 currency 키 dict로 변환."""
|
||||
tmps = self.getBalances()
|
||||
balances = {}
|
||||
balances: dict = {}
|
||||
if isinstance(tmps, dict):
|
||||
if tmps.get("error"):
|
||||
raise RuntimeError(f"getBalances: {tmps.get('error')}")
|
||||
return balances
|
||||
if not isinstance(tmps, list):
|
||||
raise RuntimeError(f"getBalances unexpected: {type(tmps)}")
|
||||
for tmp in tmps:
|
||||
if not isinstance(tmp, dict) or "currency" not in tmp:
|
||||
continue
|
||||
balances[tmp["currency"]] = {
|
||||
"balance": float(tmp["balance"]),
|
||||
"avg_buy_price": float(tmp["avg_buy_price"]),
|
||||
"avg_buy_price": float(tmp.get("avg_buy_price") or 0),
|
||||
}
|
||||
return balances
|
||||
|
||||
|
||||
435
deepcoin/ops/portfolio_report.py
Normal file
435
deepcoin/ops/portfolio_report.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
운영 포트폴리오 스냅샷·최근 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}")
|
||||
Reference in New Issue
Block a user