From bcb60ca5ae2062dbf5612f96194bb60886f68d5b Mon Sep 17 00:00:00 2001 From: tae2564 Date: Tue, 23 Dec 2025 22:24:35 +0900 Subject: [PATCH] Update to latest version for AutoCoinTrader2 --- src/holdings.py | 7 +- src/indicators.py | 390 +++++++++++++++++++++++++++++++++++++++++----- src/signals.py | 62 +++++++- 3 files changed, 410 insertions(+), 49 deletions(-) diff --git a/src/holdings.py b/src/holdings.py index c6dfa0c..efbe2a8 100644 --- a/src/holdings.py +++ b/src/holdings.py @@ -187,14 +187,15 @@ def get_upbit_balances(cfg: RuntimeConfig) -> dict | None: result: dict[str, float] = {} for item in balances: currency = (item.get("currency") or "").upper() - if currency == "KRW": - continue try: balance = float(item.get("balance", 0)) except Exception: balance = 0.0 - if balance <= MIN_TRADE_AMOUNT: + + # KRW는 극소량 체크 건너뜀 (매수 잔고 확인용) + if currency != "KRW" and balance <= MIN_TRADE_AMOUNT: continue + result[currency] = balance with _cache_lock: diff --git a/src/indicators.py b/src/indicators.py index 58fb1e2..254bb47 100644 --- a/src/indicators.py +++ b/src/indicators.py @@ -1,7 +1,9 @@ +import json import os import random import threading import time +from datetime import timedelta, timezone import pandas as pd import pandas_ta as ta @@ -10,7 +12,15 @@ from requests.exceptions import ConnectionError, RequestException, Timeout from .common import logger -__all__ = ["fetch_ohlcv", "compute_macd_hist", "compute_sma", "ta", "DataFetchError", "clear_ohlcv_cache"] +__all__ = [ + "fetch_ohlcv", + "compute_macd_hist", + "compute_sma", + "ta", + "DataFetchError", + "clear_ohlcv_cache", + "get_api_call_stats", +] class DataFetchError(Exception): @@ -19,12 +29,22 @@ class DataFetchError(Exception): pass -# OHLCV 데이터 캐시 (TTL 5분) +# 타임존: 업비트는 KST(UTC+9) 기준 +KST = timezone(timedelta(hours=9)) + +# OHLCV 데이터 캐시 (증분 업데이트 지원) # ⚠️ 메모리 캐시: 프로그램 재시작 시 자동 초기화됨 -# tf_map 수정 후에는 반드시 프로그램을 재시작하여 오염된 캐시를 제거해야 함 +# 캐시 구조: {(symbol, timeframe, limit): {'data': DataFrame, 'last_candle_time': datetime, 'cached_time': float}} _ohlcv_cache = {} _cache_lock = threading.RLock() # 캐시 동시 접근 보호 (재진입 가능) -CACHE_TTL = 300 # 5분 +CACHE_TTL = 300 # 5분 (하위 호환성, 실제로는 증분 업데이트 사용) +SAFETY_MARGIN_MINUTES = 2 # 캔들 완성 후 API 조회 대기 시간 (분) + +# API 호출 카운터 (모니터링용) +API_CALL_COUNTER = {"full": 0, "incremental": 0, "cache_hit": 0} + +# candle_count 캐시 (config.json 로드 결과) +_CANDLE_COUNT_CACHE = None def clear_ohlcv_cache(): @@ -36,63 +56,253 @@ def clear_ohlcv_cache(): def _clean_expired_cache(): - """만료된 캐시 항목 제거""" + """만료된 캐시 항목 제거 (레거시 호환성)""" global _ohlcv_cache with _cache_lock: now = time.time() - expired_keys = [k for k, (_, cached_time) in _ohlcv_cache.items() if now - cached_time >= CACHE_TTL] + expired_keys = [] + for k, v in _ohlcv_cache.items(): + # 새 구조(dict) 또는 구 구조(tuple) 모두 처리 + cached_time = v.get("cached_time") if isinstance(v, dict) else v[1] + if now - cached_time >= CACHE_TTL: + expired_keys.append(k) for k in expired_keys: del _ohlcv_cache[k] if expired_keys: logger.debug("[CACHE] 만료된 캐시 %d개 제거", len(expired_keys)) +def _parse_timeframe_to_seconds(timeframe: str) -> int: + """타임프레임 문자열을 초 단위로 변환 + + Args: + timeframe: "240m", "4h", "1d" 등 + + Returns: + 초 단위 시간 (예: "240m" → 14400) + + Raises: + ValueError: 지원하지 않는 타임프레임 형식 + """ + if timeframe.endswith("m"): + return int(timeframe[:-1]) * 60 + elif timeframe.endswith("h"): + return int(timeframe[:-1]) * 3600 + elif timeframe.endswith("d"): + return int(timeframe[:-1]) * 86400 + elif timeframe.endswith("w"): + return int(timeframe[:-1]) * 604800 + else: + raise ValueError(f"지원하지 않는 타임프레임 형식: {timeframe}") + + +def _get_next_candle_time(last_candle_time: pd.Timestamp, interval_seconds: int) -> pd.Timestamp: + """다음 완성 캔들 시각 계산 (업비트 4시간봉은 01:00 기준) + + Args: + last_candle_time: 마지막 완성 캔들 시각 + interval_seconds: 봉 주기 (초) + + Returns: + 다음 완성 캔들 예상 시각 (KST) + """ + # 타임존 처리 + if last_candle_time.tzinfo is None: + last_candle_time = last_candle_time.tz_localize(KST) + elif last_candle_time.tzinfo != KST: + last_candle_time = last_candle_time.tz_convert(KST) + + # 4시간봉(14400초)인 경우 업비트 특수 처리: 01:00, 05:00, 09:00... + if interval_seconds == 14400: # 4시간 = 240분 + current_hour = last_candle_time.hour + # 업비트 4시간봉: 1, 5, 9, 13, 17, 21 + upbit_4h_hours = [1, 5, 9, 13, 17, 21] + + # 같은 날 내 다음 시간 찾기 + for h in upbit_4h_hours: + if h > current_hour: + return last_candle_time.replace(hour=h, minute=0, second=0, microsecond=0) + + # 다음 날 01:00 (모든 시간 지남) + next_day = last_candle_time + pd.Timedelta(days=1) + return next_day.replace(hour=1, minute=0, second=0, microsecond=0) + + # 일반 봉: 단순 시간 더하기 + return last_candle_time + pd.Timedelta(seconds=interval_seconds) + + +def _should_fetch_new_candle(cache_entry: dict, timeframe: str) -> bool: + """새 완성 캔들이 생성되었는지 판단 (안전 마진 적용) + + Args: + cache_entry: 캐시 엔트리 {'data': DataFrame, 'last_candle_time': datetime, 'cached_time': float} + timeframe: "240m" 등 + + Returns: + True: 새 캔들 조회 필요, False: 캐시 재사용 + """ + interval_seconds = _parse_timeframe_to_seconds(timeframe) + last_candle_time = cache_entry["last_candle_time"] + + # 다음 완성 캔들 예상 시각 + next_candle_time = _get_next_candle_time(last_candle_time, interval_seconds) + + # 안전 마진: 캔들 완성 + N분 후에만 조회 (API 지연, 타임존 오차 대응) + safe_time = next_candle_time + pd.Timedelta(minutes=SAFETY_MARGIN_MINUTES) + + # 현재 시각 (KST) + now = pd.Timestamp.now(tz=KST) + + return now >= safe_time + + def fetch_ohlcv( symbol: str, timeframe: str, limit: int = 200, log_buffer: list = None, use_cache: bool = True ) -> pd.DataFrame: + """OHLCV 데이터 조회 (증분 업데이트 지원) + + 캐시에 완성된 201개 캔들을 저장하고, 새 캔들 생성 시 2개만 조회하여 슬라이딩 윈도우 업데이트. + + Args: + symbol: 심볼 (예: "KRW-BTC") + timeframe: 타임프레임 (예: "240m") + limit: 필요한 캔들 개수 (기본 200) + log_buffer: 로그 버퍼 + use_cache: 캐시 사용 여부 + + Returns: + DataFrame (완성된 캔들만, limit+1개 = 201개) + + Note: + - 반환되는 데이터는 이미 미완성 캔들이 제외된 상태 + - signals.py에서 다시 제거하지 않도록 주의 + - SMA200 계산을 위해 201개 유지 (200개 + 이전/현재 비교용 1개) + """ + global API_CALL_COUNTER + def _buf(level: str, msg: str): if log_buffer is not None: log_buffer.append(f"{level}: {msg}") else: getattr(logger, level.lower())(msg) - # 캐시 확인 cache_key = (symbol, timeframe, limit) - now = time.time() + # 증분 업데이트 시도 if use_cache and cache_key in _ohlcv_cache: - cached_df, cached_time = _ohlcv_cache[cache_key] - if now - cached_time < CACHE_TTL: - _buf("debug", f"[CACHE HIT] OHLCV: {symbol} {timeframe} (age: {int(now - cached_time)}s)") - return cached_df.copy() # 복사본 반환으로 원본 보호 - else: - # 만료된 캐시 제거 - del _ohlcv_cache[cache_key] - _buf("debug", f"[CACHE EXPIRED] OHLCV: {symbol} {timeframe}") + with _cache_lock: + cache_entry = _ohlcv_cache.get(cache_key) - # 주기적으로 만료 캐시 정리 - if len(_ohlcv_cache) > 10: - _clean_expired_cache() + # 구 캐시 구조(tuple) 처리 (하위 호환성) + if isinstance(cache_entry, tuple): + _buf("debug", f"[CACHE] 구 구조 감지, 재초기화: {symbol}") + del _ohlcv_cache[cache_key] + else: + # 새 캔들 생성 여부 확인 + if _should_fetch_new_candle(cache_entry, timeframe): + _buf("info", f"[증분 업데이트] {symbol} {timeframe}: 새 캔들 조회 시작") - _buf("debug", f"[CACHE MISS] OHLCV 수집 시작: {symbol} {timeframe}") + try: + # ⚠️ CRITICAL: 2개 조회 (마지막 완성 + 현재 미완성) + from .common import api_rate_limiter + + api_rate_limiter.acquire() + + tf_map = { + "1m": "minute1", + "3m": "minute3", + "5m": "minute5", + "10m": "minute10", + "15m": "minute15", + "30m": "minute30", + "60m": "minute60", + "240m": "minute240", + "1h": "minute60", + "4h": "minute240", + "1d": "day", + "1w": "week", + } + py_tf = tf_map.get(timeframe, timeframe) + + new_df = pyupbit.get_ohlcv(symbol, interval=py_tf, count=2) + API_CALL_COUNTER["incremental"] += 1 + + if new_df is None or new_df.empty: + _buf("warning", f"[증분 실패] 빈 결과, 캐시 재사용: {symbol}") + API_CALL_COUNTER["cache_hit"] += 1 + return cache_entry["data"].copy() + + # 미완성 캔들 제거 + new_complete = new_df.iloc[:-1] # 마지막 1개 제거 + + # 실제로 새 완성 캔들인지 검증 + if len(new_complete) > 0: + new_candle_time = new_complete.index[-1] + last_candle_time = cache_entry["last_candle_time"] + + if new_candle_time > last_candle_time: + # 슬라이딩 윈도우: [2번~limit번] + [새 1개] = limit개 유지 + cached_df = cache_entry["data"] + updated_df = pd.concat( + [ + cached_df.iloc[1:], # 가장 오래된 1개 제거 + new_complete.iloc[-1:], # 새 완성 캔들 1개 추가 + ], + ignore_index=False, + ) + + # limit개 유지 검증 (config.json의 candle_count) + if len(updated_df) != limit: + _buf( + "error", + f"[증분 오류] 캐시 크기 불일치 ({len(updated_df)} != {limit}), 전체 재조회", + ) + del _ohlcv_cache[cache_key] + else: + cache_entry["data"] = updated_df + cache_entry["last_candle_time"] = new_candle_time + cache_entry["cached_time"] = time.time() + _buf("info", f"[증분 성공] {symbol} 캔들 추가: {new_candle_time}") + return updated_df.copy() + else: + _buf("debug", f"[증분 스킵] 새 완성 캔들 없음: {symbol}") + API_CALL_COUNTER["cache_hit"] += 1 + return cache_entry["data"].copy() + else: + _buf("warning", f"[증분 실패] 빈 완성 캔들, 캐시 재사용: {symbol}") + API_CALL_COUNTER["cache_hit"] += 1 + return cache_entry["data"].copy() + + except Exception as e: + _buf("error", f"[증분 실패] {symbol} 오류: {e}, 캐시 재사용") + logger.exception("[증분 업데이트 실패] Fallback to cache") + API_CALL_COUNTER["cache_hit"] += 1 + return cache_entry["data"].copy() + else: + # 새 캔들 미생성, 캐시 재사용 + _buf("debug", f"[CACHE HIT] {symbol} {timeframe} (캔들 미완성)") + API_CALL_COUNTER["cache_hit"] += 1 + return cache_entry["data"].copy() + + # 초기 조회 또는 캐시 미스 + # ⚠️ IMPORTANT: config.json의 candle_count는 실제 필요한 완성 캔들 개수를 의미 + # 예: candle_count=202이면 완성된 캔들 202개 필요 + # API 조회: candle_count+1 (미완성 1개 포함) + # 미완성 제거 후: candle_count개 확보 + actual_fetch_count = limit + 1 # limit은 candle_count 값 (예: 202) + _buf("info", f"[전체 조회] {symbol} {timeframe}: {actual_fetch_count}개 캔들 조회 (완성 {limit}개 확보 목표)") - # ⚠️ CRITICAL: main.py의 minutes_to_timeframe()이 반환하는 형식과 일치해야 함 - # minutes_to_timeframe()은 "1m", "3m", "10m", "60m", "240m" 등을 반환 - # pyupbit는 "minute1", "minute240" 형식을 필요로 함 tf_map = { - # 분봉 (Upbit 지원: 1, 3, 5, 10, 15, 30, 60, 240분) "1m": "minute1", "3m": "minute3", "5m": "minute5", - "10m": "minute10", # main.py에서 사용 가능 + "10m": "minute10", "15m": "minute15", "30m": "minute30", - "60m": "minute60", # main.py에서 사용 (1시간봉) - "240m": "minute240", # ⚠️ 핵심 수정: main.py에서 4시간봉으로 사용 - # 시간 단위 별칭 (호환성) + "60m": "minute60", + "240m": "minute240", "1h": "minute60", "4h": "minute240", - # 일봉/주봉 "1d": "day", "1w": "week", } @@ -102,52 +312,74 @@ def fetch_ohlcv( jitter_factor = float(os.getenv("BACKOFF_JITTER", "0.5")) max_total_backoff = float(os.getenv("MAX_TOTAL_BACKOFF", "300")) cumulative_sleep = 0.0 + for attempt in range(1, max_attempts + 1): try: - # ✅ Rate Limiter로 API 호출 보호 from .common import api_rate_limiter api_rate_limiter.acquire() - df = pyupbit.get_ohlcv(symbol, interval=py_tf, count=limit) + # actual_fetch_count개 조회: 미완성 제거 후 limit개 확보 + df = pyupbit.get_ohlcv(symbol, interval=py_tf, count=actual_fetch_count) + API_CALL_COUNTER["full"] += 1 + if df is None or df.empty: _buf("warning", f"OHLCV 빈 결과: {symbol}") raise RuntimeError("empty ohlcv") - # 'close' 컬럼 검증 및 안전한 처리 + # 'close' 컬럼 검증 if "close" not in df.columns: if len(df.columns) >= 4: - # pyupbit OHLCV 순서: open(0), high(1), low(2), close(3), volume(4) df = df.rename(columns={df.columns[3]: "close"}) _buf("warning", f"'close' 컬럼 누락, 4번째 컬럼 사용: {symbol}") else: raise DataFetchError(f"OHLCV 데이터에 'close' 컬럼이 없고, 컬럼 수가 4개 미만: {symbol}") - # 캐시 저장 (Lock 보호) + # 미완성 캔들 제거 + df_complete = df.iloc[:-1].copy() # actual_fetch_count - 1 = limit개 + + # limit개 검증 (config.json의 candle_count와 일치) + if len(df_complete) < limit: + _buf("warning", f"데이터 부족: {symbol} ({len(df_complete)} < {limit})") + # 부족하더라도 저장 (초기 데이터 부족 상황 대응) + + # 캐시 저장 if use_cache: with _cache_lock: - _ohlcv_cache[cache_key] = (df.copy(), time.time()) - _buf("debug", f"[CACHE SAVE] OHLCV: {symbol} {timeframe}") + _ohlcv_cache[cache_key] = { + "data": df_complete, + "last_candle_time": df_complete.index[-1], + "cached_time": time.time(), + } + _buf( + "info", f"[CACHE SAVE] {symbol} {timeframe}: {len(df_complete)}개 (마지막: {df_complete.index[-1]})" + ) + + return df_complete - _buf("debug", f"OHLCV 수집 완료: {symbol}") - return df except Exception as e: is_network_err = isinstance(e, (RequestException, Timeout, ConnectionError)) _buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}") + if not is_network_err: _buf("error", f"네트워크 비관련 오류; 재시도하지 않음: {e}") raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}") from e + if attempt == max_attempts: _buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})") raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}") from e + sleep_time = base_backoff * (2 ** (attempt - 1)) sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time) + if cumulative_sleep + sleep_time > max_total_backoff: logger.warning("누적 재시도 대기시간 초과 (%s)", symbol) raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}") from e + cumulative_sleep += sleep_time _buf("debug", f"{sleep_time:.2f}초 후 재시도") time.sleep(sleep_time) + raise DataFetchError(f"OHLCV 수집 로직의 마지막에 도달했습니다. 이는 발생해서는 안 됩니다: {symbol}") @@ -184,3 +416,85 @@ def compute_sma(close_series: pd.Series, window: int, log_buffer: list = None) - except Exception as e: _buf("error", f"SMA{window} 계산 실패: {e}") raise # 예외를 호출자에게 전파하여 명시적 처리 강제 + + +def _get_candle_count() -> int: + """config.json에서 candle_count 로드 (캐싱) + + Returns: + candle_count 값 (기본값: 202) + """ + global _CANDLE_COUNT_CACHE + + if _CANDLE_COUNT_CACHE is not None: + return _CANDLE_COUNT_CACHE + + try: + config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "config.json") + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + _CANDLE_COUNT_CACHE = int(config.get("candle_count", 202)) + logger.debug("[API 통계] candle_count 로드: %d", _CANDLE_COUNT_CACHE) + except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError) as e: + logger.warning("[API 통계] config.json 로드 실패 (기본값 202 사용): %s", e) + _CANDLE_COUNT_CACHE = 202 + except Exception as e: + logger.error("[API 통계] 예기치 않은 오류 (기본값 202 사용): %s", e) + _CANDLE_COUNT_CACHE = 202 + + return _CANDLE_COUNT_CACHE + + +def get_api_call_stats() -> dict: + """API 호출 통계 반환 (모니터링용) + + Returns: + { + "total_calls": 전체 API 호출 횟수, + "full_fetch": 전체 조회 횟수, + "incremental_fetch": 증분 조회 횟수, + "cache_hit": 캐시 히트 횟수, + "incremental_ratio": 증분 조회 비율(%), + "cache_hit_ratio": 캐시 히트 비율(%), + "estimated_api_reduction": 예상 API 감소율(%) + } + """ + total = sum(API_CALL_COUNTER.values()) + if total == 0: + return { + "total_calls": 0, + "full_fetch": 0, + "incremental_fetch": 0, + "cache_hit": 0, + "incremental_ratio": 0.0, + "cache_hit_ratio": 0.0, + "estimated_api_reduction": 0.0, + } + + incremental_pct = API_CALL_COUNTER["incremental"] / total * 100 + cache_hit_pct = API_CALL_COUNTER["cache_hit"] / total * 100 + + # config.json에서 candle_count 로드 (캐싱) + candle_count = _get_candle_count() + + # 증분 조회는 2개만 받아오므로 (candle_count / 2) 배 효율 + # 전체 조회: candle_count+1개 (미완성 포함) → 실제 효율 계산은 완성 캔들 수 기준 + api_calls_without_optimization = ( + API_CALL_COUNTER["full"] * candle_count + API_CALL_COUNTER["incremental"] * candle_count + ) + api_calls_with_optimization = API_CALL_COUNTER["full"] * candle_count + API_CALL_COUNTER["incremental"] * 2 + reduction = ( + (1 - api_calls_with_optimization / api_calls_without_optimization) * 100 + if api_calls_without_optimization > 0 + else 0 + ) + + return { + "total_calls": total, + "full_fetch": API_CALL_COUNTER["full"], + "incremental_fetch": API_CALL_COUNTER["incremental"], + "cache_hit": API_CALL_COUNTER["cache_hit"], + "incremental_ratio": round(incremental_pct, 1), + "cache_hit_ratio": round(cache_hit_pct, 1), + "estimated_api_reduction": round(reduction, 1), + } diff --git a/src/signals.py b/src/signals.py index f0d93ba..e87e0f1 100644 --- a/src/signals.py +++ b/src/signals.py @@ -337,10 +337,11 @@ def _prepare_data_and_indicators( buffer.append(f"지표 계산에 충분한 데이터 없음: {symbol}") return None - # ✅ 마지막 미완성 캔들 제외 (완성된 캔들만 사용) - # 예: 21:05분에 조회 시 21:00 봉(미완성)을 제외하고 17:00 봉(완성)까지만 사용 - df_complete = df.iloc[:-1].copy() - buffer.append(f"완성된 캔들만 사용: 마지막 봉({df.index[-1]}) 제외, 최종 봉({df_complete.index[-1]})") + # ✅ fetch_ohlcv()가 이미 완성된 캔들만 반환 (201개) + # ⚠️ 주의: 미완성 캔들이 이미 제거되었으므로 iloc[:-1] 불필요 + # candle_count=202 → fetch_ohlcv는 201개 반환 (완성된 캔들만) + df_complete = df.copy() # 이미 완성된 캔들만 포함 + buffer.append(f"완성된 캔들 사용: {len(df_complete)}개 (최신: {df_complete.index[-1]})") ind = indicators or {} macd_fast = int(ind.get("macd_fast", 12)) @@ -515,13 +516,19 @@ def _safe_format(value, precision: int = 2, default: str = "N/A") -> str: def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"): """매수 신호를 처리하고, 알림을 보내거나 자동 매수를 실행합니다.""" if not evaluation.get("matches"): + logger.debug("[%s] 매수 신호 없음 (matches 비어있음)", symbol) return None data = evaluation.get("data_points", {}) close_price = data.get("close") if close_price is None: + logger.error("[%s] ❌ 매수 처리 실패: close_price is None (data_points=%s)", symbol, data.keys()) return None + logger.info( + "[%s] 🔵 매수 신호 처리 시작 (가격: %.2f, 조건: %s)", symbol, close_price, ", ".join(evaluation["matches"]) + ) + # 포매팅 헬퍼 def fmt_val(value, precision): return _safe_format(value, precision) @@ -537,35 +544,70 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"): amount_krw = float(cfg.config.get("auto_trade", {}).get("buy_amount_krw", 0) or 0) if cfg.dry_run: + logger.info("[%s] dry_run 모드: 시뮬레이션 거래 기록", symbol) trade = make_trade_record(symbol, "buy", amount_krw, True, price=close_price, status="simulated") record_trade(trade, TRADES_FILE, indicators=data) trade_recorded = True elif cfg.trading_mode == "auto_trade": + logger.debug("[%s] auto_trade 모드 진입 (amount_krw: %.0f)", symbol, amount_krw) auto_trade_cfg = cfg.config.get("auto_trade", {}) can_auto_buy = auto_trade_cfg.get("buy_enabled", False) and amount_krw > 0 + logger.debug( + "[%s] can_auto_buy 초기값: %s (buy_enabled=%s, amount_krw=%.0f)", + symbol, + can_auto_buy, + auto_trade_cfg.get("buy_enabled"), + amount_krw, + ) + if auto_trade_cfg.get("require_env_confirm", True): - can_auto_buy = can_auto_buy and os.getenv("AUTO_TRADE_ENABLED") == "1" - if auto_trade_cfg.get("allowed_symbols", []) and symbol not in auto_trade_cfg["allowed_symbols"]: + env_check = os.getenv("AUTO_TRADE_ENABLED") == "1" + can_auto_buy = can_auto_buy and env_check + logger.debug( + "[%s] require_env_confirm=True: AUTO_TRADE_ENABLED=%s → can_auto_buy=%s", + symbol, + os.getenv("AUTO_TRADE_ENABLED"), + can_auto_buy, + ) + else: + logger.debug("[%s] require_env_confirm=False: 환경변수 체크 건너뜀", symbol) + + allowed = auto_trade_cfg.get("allowed_symbols", []) + if allowed and symbol not in allowed: + logger.warning("[%s] ⚠️ allowed_symbols 제한으로 매수 차단 (허용: %s)", symbol, allowed) can_auto_buy = False + elif allowed: + logger.debug("[%s] allowed_symbols 체크 통과", symbol) + else: + logger.debug("[%s] allowed_symbols 비어있음 (모든 심볼 허용)", symbol) + + logger.info("[%s] ✅ can_auto_buy 최종 판정: %s", symbol, can_auto_buy) if can_auto_buy: from .holdings import get_upbit_balances try: balances = get_upbit_balances(cfg) - if (balances or {}).get("KRW", 0) < amount_krw: - logger.warning("[%s] 잔고 부족으로 매수 건너뜀", symbol) + krw_balance = (balances or {}).get("KRW", 0) + logger.info("[%s] KRW 잔고 확인: %.2f (필요: %.2f)", symbol, krw_balance, amount_krw) + + if krw_balance < amount_krw: + logger.warning( + "[%s] ❌ 잔고 부족으로 매수 건너뜀 (부족: %.2f KRW)", symbol, amount_krw - krw_balance + ) # ... (잔고 부족 알림) return result except Exception as e: logger.warning("[%s] 잔고 확인 실패: %s", symbol, e) + logger.info("[%s] 🚀 매수 주문 실행 시작 (금액: %.0f KRW)", symbol, amount_krw) from .order import execute_buy_order_with_confirmation buy_result = execute_buy_order_with_confirmation( symbol=symbol, amount_krw=amount_krw, cfg=cfg, indicators=data ) result["buy_order"] = buy_result + logger.debug("[%s] 매수 주문 결과: %s", symbol, buy_result.get("status", "unknown")) monitor = buy_result.get("monitor", {}) if ( @@ -574,6 +616,10 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"): ): trade_recorded = True # ... (매수 후 처리 로직: holdings 업데이트 및 거래 기록) + else: + logger.warning("[%s] ⚠️ can_auto_buy=False로 매수 주문 건너뜀 (텔레그램 알림만 전송)", symbol) + else: + logger.debug("[%s] trading_mode=%s (auto_trade 아님)", symbol, cfg.trading_mode) if not trade_recorded and not cfg.dry_run: trade = make_trade_record(symbol, "buy", amount_krw, False, price=close_price, status="notified")