feat: add daily 24h PnL Telegram report and stop tracking .env
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
209
.env
209
.env
@@ -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
|
||||
@@ -81,3 +81,11 @@ LIVE_DAILY_LOSS_LIMIT_KRW=40000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
# 시뮬 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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# ---> Python
|
||||
.env
|
||||
.idea
|
||||
*.db
|
||||
|
||||
@@ -103,4 +104,6 @@ docs/04_matching/simulation_report.html
|
||||
docs/04_matching/backtest_summary.html
|
||||
docs/04_matching/gt_overlap_report.json
|
||||
data/ops/live_sizing_state.json
|
||||
data/ops/portfolio_snapshots.jsonl
|
||||
data/ops/live_trades.jsonl
|
||||
docs/05_ops/live_verification_*.md
|
||||
|
||||
@@ -36,7 +36,7 @@ python scripts/06_execute_live.py --once
|
||||
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` (운영 전 봉 동기화)
|
||||
|
||||
## 디렉터리
|
||||
|
||||
|
||||
19
config.py
19
config.py
@@ -418,3 +418,22 @@ LIVE_HYBRID_BOOTSTRAP_FIRES = _getenv("LIVE_HYBRID_BOOTSTRAP_FIRES", "1").strip(
|
||||
"True",
|
||||
"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
@@ -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}")
|
||||
@@ -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_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"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
82
scripts/07_daily_pnl_telegram.py
Normal file
82
scripts/07_daily_pnl_telegram.py
Normal 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())
|
||||
@@ -36,6 +36,7 @@
|
||||
| `check_balance.py` | 빗썸 잔고 |
|
||||
| `06_execute_live.py` | 실거래 |
|
||||
| `05_run_monitor.py` | 알림만 (선택) |
|
||||
| `07_daily_pnl_telegram.py` | 매일 19:00 24h 수익률 텔레그램 (`--once` 즉시) |
|
||||
|
||||
## 검증
|
||||
|
||||
|
||||
Reference in New Issue
Block a user