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
|
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
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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` (운영 전 봉 동기화)
|
||||||
|
|
||||||
## 디렉터리
|
## 디렉터리
|
||||||
|
|
||||||
|
|||||||
19
config.py
19
config.py
@@ -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
@@ -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:
|
||||||
"""상시 루프."""
|
"""상시 루프."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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_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
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` | 빗썸 잔고 |
|
| `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` 즉시) |
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user