Update to latest version for AutoCoinTrader2
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user