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] = {}
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:

View File

@@ -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),
}

View File

@@ -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")