Update to latest version for AutoCoinTrader2

This commit is contained in:
2025-12-23 22:24:35 +09:00
parent 639db8ada5
commit bcb60ca5ae
3 changed files with 410 additions and 49 deletions

View File

@@ -187,14 +187,15 @@ def get_upbit_balances(cfg: RuntimeConfig) -> dict | None:
result: dict[str, float] = {} result: dict[str, float] = {}
for item in balances: for item in balances:
currency = (item.get("currency") or "").upper() currency = (item.get("currency") or "").upper()
if currency == "KRW":
continue
try: try:
balance = float(item.get("balance", 0)) balance = float(item.get("balance", 0))
except Exception: except Exception:
balance = 0.0 balance = 0.0
if balance <= MIN_TRADE_AMOUNT:
# KRW는 극소량 체크 건너뜀 (매수 잔고 확인용)
if currency != "KRW" and balance <= MIN_TRADE_AMOUNT:
continue continue
result[currency] = balance result[currency] = balance
with _cache_lock: with _cache_lock:

View File

@@ -1,7 +1,9 @@
import json
import os import os
import random import random
import threading import threading
import time import time
from datetime import timedelta, timezone
import pandas as pd import pandas as pd
import pandas_ta as ta import pandas_ta as ta
@@ -10,7 +12,15 @@ from requests.exceptions import ConnectionError, RequestException, Timeout
from .common import logger 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): class DataFetchError(Exception):
@@ -19,12 +29,22 @@ class DataFetchError(Exception):
pass 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 = {} _ohlcv_cache = {}
_cache_lock = threading.RLock() # 캐시 동시 접근 보호 (재진입 가능) _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(): def clear_ohlcv_cache():
@@ -36,63 +56,253 @@ def clear_ohlcv_cache():
def _clean_expired_cache(): def _clean_expired_cache():
"""만료된 캐시 항목 제거""" """만료된 캐시 항목 제거 (레거시 호환성)"""
global _ohlcv_cache global _ohlcv_cache
with _cache_lock: with _cache_lock:
now = time.time() 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: for k in expired_keys:
del _ohlcv_cache[k] del _ohlcv_cache[k]
if expired_keys: if expired_keys:
logger.debug("[CACHE] 만료된 캐시 %d개 제거", len(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( def fetch_ohlcv(
symbol: str, timeframe: str, limit: int = 200, log_buffer: list = None, use_cache: bool = True symbol: str, timeframe: str, limit: int = 200, log_buffer: list = None, use_cache: bool = True
) -> pd.DataFrame: ) -> 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): def _buf(level: str, msg: str):
if log_buffer is not None: if log_buffer is not None:
log_buffer.append(f"{level}: {msg}") log_buffer.append(f"{level}: {msg}")
else: else:
getattr(logger, level.lower())(msg) getattr(logger, level.lower())(msg)
# 캐시 확인
cache_key = (symbol, timeframe, limit) cache_key = (symbol, timeframe, limit)
now = time.time()
# 증분 업데이트 시도
if use_cache and cache_key in _ohlcv_cache: if use_cache and cache_key in _ohlcv_cache:
cached_df, cached_time = _ohlcv_cache[cache_key] with _cache_lock:
if now - cached_time < CACHE_TTL: cache_entry = _ohlcv_cache.get(cache_key)
_buf("debug", f"[CACHE HIT] OHLCV: {symbol} {timeframe} (age: {int(now - cached_time)}s)")
return cached_df.copy() # 복사본 반환으로 원본 보호 # 구 캐시 구조(tuple) 처리 (하위 호환성)
else: if isinstance(cache_entry, tuple):
# 만료된 캐시 제거 _buf("debug", f"[CACHE] 구 구조 감지, 재초기화: {symbol}")
del _ohlcv_cache[cache_key] del _ohlcv_cache[cache_key]
_buf("debug", f"[CACHE EXPIRED] OHLCV: {symbol} {timeframe}") else:
# 새 캔들 생성 여부 확인
if _should_fetch_new_candle(cache_entry, timeframe):
_buf("info", f"[증분 업데이트] {symbol} {timeframe}: 새 캔들 조회 시작")
# 주기적으로 만료 캐시 정리 try:
if len(_ohlcv_cache) > 10: # ⚠️ CRITICAL: 2개 조회 (마지막 완성 + 현재 미완성)
_clean_expired_cache() from .common import api_rate_limiter
_buf("debug", f"[CACHE MISS] OHLCV 수집 시작: {symbol} {timeframe}") api_rate_limiter.acquire()
# ⚠️ CRITICAL: main.py의 minutes_to_timeframe()이 반환하는 형식과 일치해야 함
# minutes_to_timeframe()은 "1m", "3m", "10m", "60m", "240m" 등을 반환
# pyupbit는 "minute1", "minute240" 형식을 필요로 함
tf_map = { tf_map = {
# 분봉 (Upbit 지원: 1, 3, 5, 10, 15, 30, 60, 240분)
"1m": "minute1", "1m": "minute1",
"3m": "minute3", "3m": "minute3",
"5m": "minute5", "5m": "minute5",
"10m": "minute10", # main.py에서 사용 가능 "10m": "minute10",
"15m": "minute15", "15m": "minute15",
"30m": "minute30", "30m": "minute30",
"60m": "minute60", # main.py에서 사용 (1시간봉) "60m": "minute60",
"240m": "minute240", # ⚠️ 핵심 수정: main.py에서 4시간봉으로 사용 "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}개 확보 목표)")
tf_map = {
"1m": "minute1",
"3m": "minute3",
"5m": "minute5",
"10m": "minute10",
"15m": "minute15",
"30m": "minute30",
"60m": "minute60",
"240m": "minute240",
"1h": "minute60", "1h": "minute60",
"4h": "minute240", "4h": "minute240",
# 일봉/주봉
"1d": "day", "1d": "day",
"1w": "week", "1w": "week",
} }
@@ -102,52 +312,74 @@ def fetch_ohlcv(
jitter_factor = float(os.getenv("BACKOFF_JITTER", "0.5")) jitter_factor = float(os.getenv("BACKOFF_JITTER", "0.5"))
max_total_backoff = float(os.getenv("MAX_TOTAL_BACKOFF", "300")) max_total_backoff = float(os.getenv("MAX_TOTAL_BACKOFF", "300"))
cumulative_sleep = 0.0 cumulative_sleep = 0.0
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
try: try:
# ✅ Rate Limiter로 API 호출 보호
from .common import api_rate_limiter from .common import api_rate_limiter
api_rate_limiter.acquire() 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: if df is None or df.empty:
_buf("warning", f"OHLCV 빈 결과: {symbol}") _buf("warning", f"OHLCV 빈 결과: {symbol}")
raise RuntimeError("empty ohlcv") raise RuntimeError("empty ohlcv")
# 'close' 컬럼 검증 및 안전한 처리 # 'close' 컬럼 검증
if "close" not in df.columns: if "close" not in df.columns:
if len(df.columns) >= 4: 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"}) df = df.rename(columns={df.columns[3]: "close"})
_buf("warning", f"'close' 컬럼 누락, 4번째 컬럼 사용: {symbol}") _buf("warning", f"'close' 컬럼 누락, 4번째 컬럼 사용: {symbol}")
else: else:
raise DataFetchError(f"OHLCV 데이터에 'close' 컬럼이 없고, 컬럼 수가 4개 미만: {symbol}") 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: if use_cache:
with _cache_lock: with _cache_lock:
_ohlcv_cache[cache_key] = (df.copy(), time.time()) _ohlcv_cache[cache_key] = {
_buf("debug", f"[CACHE SAVE] OHLCV: {symbol} {timeframe}") "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: except Exception as e:
is_network_err = isinstance(e, (RequestException, Timeout, ConnectionError)) is_network_err = isinstance(e, (RequestException, Timeout, ConnectionError))
_buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}") _buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}")
if not is_network_err: if not is_network_err:
_buf("error", f"네트워크 비관련 오류; 재시도하지 않음: {e}") _buf("error", f"네트워크 비관련 오류; 재시도하지 않음: {e}")
raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}") from e raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}") from e
if attempt == max_attempts: if attempt == max_attempts:
_buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})") _buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})")
raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}") from e raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}") from e
sleep_time = base_backoff * (2 ** (attempt - 1)) sleep_time = base_backoff * (2 ** (attempt - 1))
sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time) sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time)
if cumulative_sleep + sleep_time > max_total_backoff: if cumulative_sleep + sleep_time > max_total_backoff:
logger.warning("누적 재시도 대기시간 초과 (%s)", symbol) logger.warning("누적 재시도 대기시간 초과 (%s)", symbol)
raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}") from e raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}") from e
cumulative_sleep += sleep_time cumulative_sleep += sleep_time
_buf("debug", f"{sleep_time:.2f}초 후 재시도") _buf("debug", f"{sleep_time:.2f}초 후 재시도")
time.sleep(sleep_time) time.sleep(sleep_time)
raise DataFetchError(f"OHLCV 수집 로직의 마지막에 도달했습니다. 이는 발생해서는 안 됩니다: {symbol}") 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: except Exception as e:
_buf("error", f"SMA{window} 계산 실패: {e}") _buf("error", f"SMA{window} 계산 실패: {e}")
raise # 예외를 호출자에게 전파하여 명시적 처리 강제 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),
}

View File

@@ -337,10 +337,11 @@ def _prepare_data_and_indicators(
buffer.append(f"지표 계산에 충분한 데이터 없음: {symbol}") buffer.append(f"지표 계산에 충분한 데이터 없음: {symbol}")
return None return None
# ✅ 마지막 미완성 캔들 제외 (완성된 캔들만 사용) # ✅ fetch_ohlcv()가 이미 완성된 캔들만 반환 (201개)
# 예: 21:05분에 조회 시 21:00 봉(미완성)을 제외하고 17:00 봉(완성)까지만 사용 # ⚠️ 주의: 미완성 캔들이 이미 제거되었으므로 iloc[:-1] 불필요
df_complete = df.iloc[:-1].copy() # candle_count=202 → fetch_ohlcv는 201개 반환 (완성된 캔들만)
buffer.append(f"완성된 캔들만 사용: 마지막 봉({df.index[-1]}) 제외, 최종 봉({df_complete.index[-1]})") df_complete = df.copy() # 이미 완성된 캔들만 포함
buffer.append(f"완성된 캔들 사용: {len(df_complete)}개 (최신: {df_complete.index[-1]})")
ind = indicators or {} ind = indicators or {}
macd_fast = int(ind.get("macd_fast", 12)) 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"): def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
"""매수 신호를 처리하고, 알림을 보내거나 자동 매수를 실행합니다.""" """매수 신호를 처리하고, 알림을 보내거나 자동 매수를 실행합니다."""
if not evaluation.get("matches"): if not evaluation.get("matches"):
logger.debug("[%s] 매수 신호 없음 (matches 비어있음)", symbol)
return None return None
data = evaluation.get("data_points", {}) data = evaluation.get("data_points", {})
close_price = data.get("close") close_price = data.get("close")
if close_price is None: if close_price is None:
logger.error("[%s] ❌ 매수 처리 실패: close_price is None (data_points=%s)", symbol, data.keys())
return None return None
logger.info(
"[%s] 🔵 매수 신호 처리 시작 (가격: %.2f, 조건: %s)", symbol, close_price, ", ".join(evaluation["matches"])
)
# 포매팅 헬퍼 # 포매팅 헬퍼
def fmt_val(value, precision): def fmt_val(value, precision):
return _safe_format(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) amount_krw = float(cfg.config.get("auto_trade", {}).get("buy_amount_krw", 0) or 0)
if cfg.dry_run: if cfg.dry_run:
logger.info("[%s] dry_run 모드: 시뮬레이션 거래 기록", symbol)
trade = make_trade_record(symbol, "buy", amount_krw, True, price=close_price, status="simulated") trade = make_trade_record(symbol, "buy", amount_krw, True, price=close_price, status="simulated")
record_trade(trade, TRADES_FILE, indicators=data) record_trade(trade, TRADES_FILE, indicators=data)
trade_recorded = True trade_recorded = True
elif cfg.trading_mode == "auto_trade": 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", {}) auto_trade_cfg = cfg.config.get("auto_trade", {})
can_auto_buy = auto_trade_cfg.get("buy_enabled", False) and amount_krw > 0 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): if auto_trade_cfg.get("require_env_confirm", True):
can_auto_buy = can_auto_buy and os.getenv("AUTO_TRADE_ENABLED") == "1" env_check = os.getenv("AUTO_TRADE_ENABLED") == "1"
if auto_trade_cfg.get("allowed_symbols", []) and symbol not in auto_trade_cfg["allowed_symbols"]: 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 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: if can_auto_buy:
from .holdings import get_upbit_balances from .holdings import get_upbit_balances
try: try:
balances = get_upbit_balances(cfg) balances = get_upbit_balances(cfg)
if (balances or {}).get("KRW", 0) < amount_krw: krw_balance = (balances or {}).get("KRW", 0)
logger.warning("[%s] 잔고 부족으로 매수 건너뜀", symbol) 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 return result
except Exception as e: except Exception as e:
logger.warning("[%s] 잔고 확인 실패: %s", symbol, e) logger.warning("[%s] 잔고 확인 실패: %s", symbol, e)
logger.info("[%s] 🚀 매수 주문 실행 시작 (금액: %.0f KRW)", symbol, amount_krw)
from .order import execute_buy_order_with_confirmation from .order import execute_buy_order_with_confirmation
buy_result = execute_buy_order_with_confirmation( buy_result = execute_buy_order_with_confirmation(
symbol=symbol, amount_krw=amount_krw, cfg=cfg, indicators=data symbol=symbol, amount_krw=amount_krw, cfg=cfg, indicators=data
) )
result["buy_order"] = buy_result result["buy_order"] = buy_result
logger.debug("[%s] 매수 주문 결과: %s", symbol, buy_result.get("status", "unknown"))
monitor = buy_result.get("monitor", {}) monitor = buy_result.get("monitor", {})
if ( if (
@@ -574,6 +616,10 @@ def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
): ):
trade_recorded = True trade_recorded = True
# ... (매수 후 처리 로직: holdings 업데이트 및 거래 기록) # ... (매수 후 처리 로직: 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: if not trade_recorded and not cfg.dry_run:
trade = make_trade_record(symbol, "buy", amount_krw, False, price=close_price, status="notified") trade = make_trade_record(symbol, "buy", amount_krw, False, price=close_price, status="notified")