feat: add daily 24h PnL Telegram report and stop tracking .env

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-03 16:46:17 +01:00
parent b0d29353a3
commit ef8fc774f8
14 changed files with 13568 additions and 13433 deletions

209
.env
View File

@@ -1,209 +0,0 @@
# DeepCoin 로컬 설정 (Git 제외). 설정 변경은 이 파일만 수정하세요.
# --- 빗썸 API ---
BITHUMB_ACCESS_KEY=4d8782eb50f40a9efe0fed9667e5a95286f69765c53d87
BITHUMB_SECRET_KEY=ZWVkYzU4OWM3NDkwNWZmNTg0ZWQ1NGY0MDQ5MzhiN2ZlYjk5NmZlMGFjNTM4NGY1OWU2NWM4M2M4NmMzNA==
BITHUMB_API_URL=https://api.bithumb.com
BITHUMB_API_CANDLE_COUNT=200
BITHUMB_MINUTE_INTERVALS=1,3,5,10,15,30,60,240
HTS_API_RETRY_SLEEP_SEC=0.5
# --- 텔레그램 (선택, 알림 미사용 시 비워도 됨) ---
COIN_TELEGRAM_BOT_TOKEN=6435061393:AAHOh9wB5yGNGUdb3SfCYJrrWTBe7wgConM
COIN_TELEGRAM_CHAT_ID=574661323
# --- 거래 대상 ---
SYMBOL=WLD
COIN_NAME=월드코인
# --- 경로 ---
DB_PATH=data/coins.db
GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json
# --- 타임프레임 (분) ---
DAILY_INTERVAL_MIN=1440
ENTRY_INTERVAL=3
TREND_INTERVAL_1H=60
TREND_INTERVAL_1D=1440
ALL_INTERVALS=3,5,10,15,30,60,240,1440
DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440
GENERAL_ANALYSIS_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200
TIMING_INTERVALS=3,5,10,15
TREND_INTERVALS=60,240,1440,10080,43200
INTERVAL_PREFIX=3:m3,5:m5,10:m10,15:m15,30:m30,60:m60,240:m240,1440:d1,10080:w1,43200:mo1
# --- 볼린저 / RSI ---
BB_PERIOD=20
BB_STD=2
BB_MIN_WIDTH_PCT=0.8
RSI_PERIOD=14
DISPARITY_PERIODS=5,20,60
DISPARITY_OVERBOUGHT=105
DISPARITY_OVERSOLD=95
MACD_FAST=12
MACD_SLOW=26
MACD_SIGNAL=9
STOCH_K_PERIOD=14
STOCH_D_PERIOD=3
STOCH_SMOOTH_K=3
STOCH_OVERSOLD=20
STOCH_OVERBOUGHT=80
TREND_RANGE_MA_GAP_PCT=0.5
# --- MTF 정렬 ---
ALIGN_RSI_OVERSOLD=35
ALIGN_RSI_OVERBOUGHT=65
ALIGN_RSI_CONFLICT_TIMING_LOW=40
ALIGN_RSI_CONFLICT_TIMING_HIGH=65
ALIGN_RSI_CONFLICT_TREND_LOW=40
ALIGN_RSI_CONFLICT_TREND_HIGH=65
ALIGN_BB_POS_LOW=0.2
ALIGN_BB_POS_HIGH=0.8
# --- 다운로드 / DB ---
DOWNLOAD_MONTHS=12
INCREMENTAL_OVERLAP_BARS=3
DOWNLOAD_BACKFILL_EXTRA_BARS=200
DOWNLOAD_MIN_INCREMENTAL_BARS=50
DOWNLOAD_DAILY_EXTRA_DAYS=20
CHART_LOOKBACK_DAYS=365
DB_READ_LIMIT_DEFAULT=7000
DB_ROW_WARMUP_BARS=200
DB_ROW_MIN_DAILY_BARS=100
DB_ROW_DAILY_PADDING_DAYS=30
# --- Ground Truth ---
GT_MIN_SWING_PCT=4.0
GT_PIVOT_ORDER=20
GT_MIN_BARS_BETWEEN=30
GT_MAX_ROUND_TRIPS=24
GT_SELECTION_MODE=split_buy_peak_sell
GT_MIN_LEG_PCT=8.0
GT_BUY_MIN_SWING_PCT=3.0
GT_BUY_BB_MAX=0.45
GT_BUY_MIN_BARS=24
GT_MAX_BUYS_PER_LEG=12
GT_MAX_SELLS_PER_LEG=2
GT_SELL_SPLIT_GAP_PCT=2.5
GT_MARKER_SIZE_MIN=10
GT_MARKER_SIZE_MAX=32
GT_INITIAL_CASH_KRW=400000
TRADING_FEE_RATE=0.0005
GT_UNLIMITED_CHRONOLOGICAL_DAYS=300
# --- 모니터 ---
MONITOR_LOOP_SLEEP_SEC=180
MONITOR_POOL_WORKERS=12
MONITOR_DEFAULT_INTERVAL=60
MONITOR_API_RETRIES=3
MONITOR_API_BONG_COUNT=3000
MONITOR_SLEEP_AFTER_REQUEST_SEC=0.5
MONITOR_SLEEP_RATE_LIMIT_SEC=5
MONITOR_SLEEP_BETWEEN_CHUNKS_SEC=0.3
MONITOR_API_CHUNK_BARS=200
MONITOR_MA_WINDOWS=5,20,40,120,200,240,720,1440
MONITOR_NORM_WINDOW=20
MONITOR_TELEGRAM_BATCH_SIZE=20
# --- general_analysis ---
GA_COL_PREFIX=ga_
LOOKBACK_BARS=3:120,5:100,10:80,15:60,30:50,60:40,240:30,1440:60,10080:12,43200:6
CONTEXT_TAIL_ROWS=3:6000,5:5000,10:4000,15:3000,30:2000,60:1500,240:800,1440:500,10080:120,43200:48
GA_DEFAULT_TAIL_EXPORT=200
GA_PATTERN_TOLERANCE_PCT=2.5
GA_VP_BINS=30
GA_VP_VALUE_AREA_PCT=0.70
GA_HV_ROLLING_BARS=20
GA_HV_PERCENTILE_WINDOW=120
GA_HV_ANNUALIZE_SQRT=339.41148133
GA_DIVERGENCE_LOOKBACK=10
GA_SMA_PERIODS=5,20,60,120
GA_EMA_SPANS=12,26
GA_ATR_PERIOD=14
GA_KELTNER_ATR_MULT=2
GA_AO_FAST=5
GA_AO_SLOW=34
GA_LINREG_WINDOW=20
GA_ADX_PERIOD=14
GA_ADX_TREND_THRESHOLD=25
GA_SUPERTREND_ATR_MULT=3
GA_VOL_SPIKE_MULT=1.8
GA_VOL_MA_WINDOW=20
GA_CCI_PERIOD=20
GA_WILLIAMS_PERIOD=14
GA_ROC_PERIOD=10
GA_MFI_PERIOD=14
GA_CMF_PERIOD=20
GA_DONCHIAN_PERIOD=20
GA_BB_SQUEEZE_WINDOW=50
GA_BB_SQUEEZE_QUANTILE=0.2
GA_PIVOT_ORDER=3
GA_PSAR_AF_START=0.02
GA_PSAR_AF_STEP=0.02
GA_PSAR_AF_MAX=0.2
# --- .env.example 누락 키 추가 (2026-06-01) ---
GT_MIN_ORDER_KRW=5000
GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05
GT_LARGE_LEG_TOP_PCT=0.2
GT_SIGNAL_CAUSAL=1
SIM_CAUSAL_TIER=1
CAUSAL_GT_PEAK_MODE=local
CAUSAL_GT_MIN_LEG_PCT=5.0
CAUSAL_GT_MIN_BARS_BETWEEN_LEGS=60
CAUSAL_GT_USE_LOCAL_TROUGH=1
CAUSAL_GT_DD_LARGE_PCT=5.0
CAUSAL_GT_DD_MEDIUM_PCT=2.0
GT_BUY_PCT_MEDIUM_LEG=0.25
SIM_TIER_CONVICTION_DD_PCT=10.0
SIM_PRIMARY_SIZING=auto
SIM_HYBRID_MIN_HOLDOUT_PNL_PCT=0.0
SIM_HYBRID_MAX_MDD_PCT=30.0
SIM_OPTION_C_TARGET_PNL_PCT=300.0
SIM_OPTION_C_PHASE2_TARGET_PNL_PCT=1000.0
SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO=0.85
SIM_OPTION_C_MIN_GT_CAPTURE=0.23
SIM_HYBRID_PORTFOLIO_WF_MIN_RATIO=0.5
GT_BUY_WEIGHT_RULE=inverse_price_normalized
GT_SELL_SPLIT_WEIGHTS=0.65,0.35
MATCH_LABEL_MODE=leg_gt
MATCH_HOLDOUT_RATIO=0.15
MATCH_MONITOR_MAX_PER_SIDE=1
SIM_GO_WF_POSITIVE_RATIO=0.5
SIM_FEE_STRESS_MULT=2.0
# 3분봉(MATCH_PRIMARY_INTERVAL=3) — 규칙·알림 쿨다운 1봉
MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_ALERT_KRW_AMOUNT=40000
# Phase B-1: 실거래 (06_execute_live.py 만 빗썸 주문 — 05는 알림만)
LIVE_TRADING_ENABLED=1
# LIVE_* 원화 한도: GT_INITIAL_CASH_KRW(40만) — B-1: 일한도 1배, 손실 10%, 1회참고 10%
LIVE_ORDER_KRW=40000
LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05
LIVE_DAILY_KRW_MAX=400000
LIVE_COOLDOWN_MIN=3
LIVE_MAX_TRADES_PER_DAY=15
LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05
# 06: 시뮬 sim_causal_hybrid 정합 — fire_outcomes monitor 발화 부트스트랩
LIVE_HYBRID_BOOTSTRAP_FIRES=1
# 07 일일 24h 수익률 텔레그램 (scripts/07_daily_pnl_telegram.py)
DAILY_PNL_REPORT_ENABLED=1
DAILY_PNL_REPORT_HOUR=19
DAILY_PNL_REPORT_MINUTE=0
DAILY_PNL_REPORT_TZ=Asia/Seoul
DAILY_PNL_SNAPSHOT_ON_LIVE=1
DAILY_PNL_SNAPSHOT_RETENTION_DAYS=90
# 05/06 루프 시 봉 DB 증분 · live_eval 캐시(루프 주기와 동일)
MONITOR_PERSIST_CANDLES=1
MATCH_LIVE_CACHE_SEC=180
# 05/06 시작·루프마다 지연 봉 자동 보완 (간격당 허용 지연 = 간격분×OPS_SYNC_MAX_LAG_BARS)
OPS_SYNC_ON_START=1
OPS_SYNC_MAX_LAG_BARS=2
# --- 주·월봉 다운로드 (01_download) ---
DOWNLOAD_INTERVALS_WM=10080,43200
DOWNLOAD_MONTHS_WM=24
WEEK_INTERVAL_MIN=10080
MONTH_INTERVAL_MIN=43200

View File

@@ -81,3 +81,11 @@ LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05
# 시뮬 sim_causal_hybrid 와 동일: fire_outcomes monitor 발화 부트스트랩 # 시뮬 sim_causal_hybrid 와 동일: fire_outcomes monitor 발화 부트스트랩
LIVE_HYBRID_BOOTSTRAP_FIRES=1 LIVE_HYBRID_BOOTSTRAP_FIRES=1
# 07 일일 24h 수익률 텔레그램 (scripts/07_daily_pnl_telegram.py)
DAILY_PNL_REPORT_ENABLED=1
DAILY_PNL_REPORT_HOUR=19
DAILY_PNL_REPORT_MINUTE=0
DAILY_PNL_REPORT_TZ=Asia/Seoul
DAILY_PNL_SNAPSHOT_ON_LIVE=1
DAILY_PNL_SNAPSHOT_RETENTION_DAYS=90

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
# ---> Python # ---> Python
.env
.idea .idea
*.db *.db
@@ -103,4 +104,6 @@ docs/04_matching/simulation_report.html
docs/04_matching/backtest_summary.html docs/04_matching/backtest_summary.html
docs/04_matching/gt_overlap_report.json docs/04_matching/gt_overlap_report.json
data/ops/live_sizing_state.json data/ops/live_sizing_state.json
data/ops/portfolio_snapshots.jsonl
data/ops/live_trades.jsonl
docs/05_ops/live_verification_*.md docs/05_ops/live_verification_*.md

View File

@@ -36,7 +36,7 @@ python scripts/06_execute_live.py --once
python scripts/06_execute_live.py python scripts/06_execute_live.py
``` ```
선택: `05_run_monitor.py` (알림만), `00_sync_ops.py` (운영 전 봉 동기화) 선택: `05_run_monitor.py` (알림만), `07_daily_pnl_telegram.py` (매일 24h 수익률), `00_sync_ops.py` (운영 전 봉 동기화)
## 디렉터리 ## 디렉터리

View File

@@ -418,3 +418,22 @@ LIVE_HYBRID_BOOTSTRAP_FIRES = _getenv("LIVE_HYBRID_BOOTSTRAP_FIRES", "1").strip(
"True", "True",
"yes", "yes",
) )
# --- 일일 수익률 텔레그램 (07_daily_pnl_telegram) ---
DAILY_PNL_REPORT_ENABLED = _getenv("DAILY_PNL_REPORT_ENABLED", "1").strip() in (
"1",
"true",
"True",
"yes",
)
DAILY_PNL_REPORT_HOUR = _getenv_int("DAILY_PNL_REPORT_HOUR", "19")
DAILY_PNL_REPORT_MINUTE = _getenv_int("DAILY_PNL_REPORT_MINUTE", "0")
DAILY_PNL_REPORT_TZ = _getenv("DAILY_PNL_REPORT_TZ", "Asia/Seoul")
# 06 루프마다 스냅샷 적재 → 24시간 전 자산 비교 가능
DAILY_PNL_SNAPSHOT_ON_LIVE = _getenv("DAILY_PNL_SNAPSHOT_ON_LIVE", "1").strip() in (
"1",
"true",
"True",
"yes",
)
DAILY_PNL_SNAPSHOT_RETENTION_DAYS = _getenv_int("DAILY_PNL_SNAPSHOT_RETENTION_DAYS", "90")

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ from deepcoin.ops.hybrid_sim_execution import (
sort_hits_sim_order, sort_hits_sim_order,
) )
from deepcoin.ops.monitor import Monitor from deepcoin.ops.monitor import Monitor
from deepcoin.ops.portfolio_report import maybe_record_portfolio_snapshot
from deepcoin.paths import ( from deepcoin.paths import (
LIVE_SIGNAL_HISTORY_JSON, LIVE_SIGNAL_HISTORY_JSON,
LIVE_TRADES_LOG, LIVE_TRADES_LOG,
@@ -307,11 +308,13 @@ class LiveTrader(Monitor):
) )
if not rules: if not rules:
print(" monitor_rules 없음 — scripts/04_match_rules.py 실행") print(" monitor_rules 없음 — scripts/04_match_rules.py 실행")
maybe_record_portfolio_snapshot(self)
return return
fired = evaluate_live_rules(rules, force_refresh=True) fired = evaluate_live_rules(rules, force_refresh=True)
if not fired: if not fired:
print(" 발화 없음") print(" 발화 없음")
maybe_record_portfolio_snapshot(self)
return return
try: try:
@@ -360,6 +363,7 @@ class LiveTrader(Monitor):
) )
msg += f"\n[체결] {log['message']}" msg += f"\n[체결] {log['message']}"
self._send_coin_msg(msg) self._send_coin_msg(msg)
maybe_record_portfolio_snapshot(self)
def run_loop(self, sleep_sec: int) -> None: def run_loop(self, sleep_sec: int) -> None:
"""상시 루프.""" """상시 루프."""

View File

@@ -267,11 +267,19 @@ class Monitor(HTS):
def load_balances_dict(self) -> dict: def load_balances_dict(self) -> dict:
"""getBalances() 결과를 currency 키 dict로 변환.""" """getBalances() 결과를 currency 키 dict로 변환."""
tmps = self.getBalances() 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: for tmp in tmps:
if not isinstance(tmp, dict) or "currency" not in tmp:
continue
balances[tmp["currency"]] = { balances[tmp["currency"]] = {
"balance": float(tmp["balance"]), "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 return balances

View 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}")

View File

@@ -51,6 +51,7 @@ MATCHING_HYBRID_DD_CALIBRATION_JSON = DOCS_MATCHING / "hybrid_dd_calibration.jso
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl" LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl"
LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json" LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json"
PORTFOLIO_SNAPSHOTS_LOG = OPS_STATE_DIR / "portfolio_snapshots.jsonl"
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html" CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
매일 지정 시각(기본 19:00 KST) 최근 24시간 수익률 텔레그램 발송.
사용:
python scripts/07_daily_pnl_telegram.py --once # 즉시 1회 발송
python scripts/07_daily_pnl_telegram.py # 스케줄 루프 (상시)
python scripts/07_daily_pnl_telegram.py --record # 스냅샷만 기록
Windows 작업 스케줄러: 매일 19:00에 --once 실행도 가능.
"""
from __future__ import annotations
import argparse
import runpy
import sys
from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
from config import ( # noqa: E402
DAILY_PNL_REPORT_ENABLED,
DAILY_PNL_REPORT_HOUR,
DAILY_PNL_REPORT_MINUTE,
DAILY_PNL_REPORT_TZ,
)
from deepcoin.ops.monitor import Monitor # noqa: E402
from deepcoin.ops.portfolio_report import ( # noqa: E402
append_portfolio_snapshot,
fetch_portfolio_snapshot,
run_schedule_loop,
send_daily_pnl_report,
)
def main() -> int:
"""
CLI 진입점.
Returns:
0 성공, 1 비활성/오류.
"""
parser = argparse.ArgumentParser(description="일일 24h 수익률 텔레그램")
parser.add_argument(
"--once",
action="store_true",
help="즉시 1회 발송 후 종료",
)
parser.add_argument(
"--record",
action="store_true",
help="스냅샷만 기록 (텔레그램 없음)",
)
args = parser.parse_args()
if not DAILY_PNL_REPORT_ENABLED:
print("DAILY_PNL_REPORT_ENABLED=0 — 종료")
return 1
print(
f"[07] DAILY_PNL · {DAILY_PNL_REPORT_HOUR:02d}:"
f"{DAILY_PNL_REPORT_MINUTE:02d} {DAILY_PNL_REPORT_TZ}"
)
mon = Monitor(cooldown_file=None)
if args.record:
snap = fetch_portfolio_snapshot(mon)
append_portfolio_snapshot(snap)
print(f"[07] snapshot total=₩{snap['total_asset_krw']:,.0f}")
return 0
if args.once:
send_daily_pnl_report(mon)
return 0
run_schedule_loop(mon)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -36,6 +36,7 @@
| `check_balance.py` | 빗썸 잔고 | | `check_balance.py` | 빗썸 잔고 |
| `06_execute_live.py` | 실거래 | | `06_execute_live.py` | 실거래 |
| `05_run_monitor.py` | 알림만 (선택) | | `05_run_monitor.py` | 알림만 (선택) |
| `07_daily_pnl_telegram.py` | 매일 19:00 24h 수익률 텔레그램 (`--once` 즉시) |
## 검증 ## 검증