테스트 강화 및 코드 품질 개선
This commit is contained in:
314
src/order.py
314
src/order.py
@@ -5,6 +5,8 @@ import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal, getcontext
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyupbit
|
||||
@@ -12,19 +14,25 @@ import requests
|
||||
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .common import HOLDINGS_FILE, MIN_KRW_ORDER, PENDING_ORDERS_FILE, logger
|
||||
from .constants import ORDER_MAX_RETRIES, ORDER_RETRY_DELAY, PENDING_ORDER_TTL
|
||||
from .notifications import send_telegram
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import RuntimeConfig
|
||||
|
||||
|
||||
def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str]:
|
||||
# Decimal 연산 정밀도 설정 (가격/수량 계산 안정화)
|
||||
getcontext().prec = 28
|
||||
|
||||
|
||||
def validate_upbit_api_keys(access_key: str, secret_key: str, check_trade_permission: bool = True) -> tuple[bool, str]:
|
||||
"""
|
||||
Upbit API 키의 유효성을 검증합니다.
|
||||
Upbit API 키의 유효성을 검증합니다 (LOW-005: 강화된 검증).
|
||||
|
||||
Args:
|
||||
access_key: Upbit 액세스 키
|
||||
secret_key: Upbit 시크릿 키
|
||||
check_trade_permission: 주문 권한 검증 여부 (기본값: True)
|
||||
|
||||
Returns:
|
||||
(유효성 여부, 메시지)
|
||||
@@ -36,7 +44,8 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
|
||||
|
||||
try:
|
||||
upbit = pyupbit.Upbit(access_key, secret_key)
|
||||
# 간단한 테스트: 잔고 조회
|
||||
|
||||
# 1단계: 잔고 조회 (읽기 권한)
|
||||
balances = upbit.get_balances()
|
||||
|
||||
if balances is None:
|
||||
@@ -46,10 +55,41 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
|
||||
error_msg = balances.get("error", {}).get("message", "Unknown error")
|
||||
return False, f"Upbit 오류: {error_msg}"
|
||||
|
||||
# 2단계: 주문 권한 검증 (선택적)
|
||||
if check_trade_permission:
|
||||
logger.debug("[검증] 주문 권한 확인 중...")
|
||||
|
||||
# 실제 주문하지 않고 권한만 확인 (극소량 테스트 주문)
|
||||
# 참고: pyupbit는 실제로 주문을 생성하므로, 대신 주문 목록 조회로 권한 확인
|
||||
try:
|
||||
orders = upbit.get_orders(ticker="KRW-BTC", state="wait")
|
||||
|
||||
# 주문 목록 조회 성공 = 주문 API 접근 가능
|
||||
if orders is None:
|
||||
logger.warning("[검증] 주문 목록 조회 실패 (None 응답), 주문 권한 미확인")
|
||||
elif isinstance(orders, dict) and "error" in orders:
|
||||
error_msg = orders.get("error", {}).get("message", "Unknown error")
|
||||
if "invalid" in error_msg.lower() or "permission" in error_msg.lower():
|
||||
return False, f"주문 권한 없음: {error_msg}"
|
||||
logger.warning("[검증] 주문 API 오류: %s (읽기 권한은 있음)", error_msg)
|
||||
else:
|
||||
logger.debug("[검증] 주문 권한 확인 완료 (주문 목록 조회 성공)")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# 401/403: 권한 없음
|
||||
if e.response.status_code in [401, 403]:
|
||||
return False, f"주문 권한 없음 (HTTP {e.response.status_code})"
|
||||
logger.warning("[검증] 주문 권한 확인 중 HTTP 오류: %s (읽기 권한은 있음)", e)
|
||||
except Exception as e:
|
||||
logger.warning("[검증] 주문 권한 확인 중 예외: %s (읽기 권한은 있음)", e)
|
||||
|
||||
# 성공: 유효한 키
|
||||
logger.info(
|
||||
"[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개", len(balances) if isinstance(balances, list) else 0
|
||||
)
|
||||
asset_count = len(balances) if isinstance(balances, list) else 0
|
||||
if check_trade_permission:
|
||||
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개, 주문 권한 검증 완료", asset_count)
|
||||
else:
|
||||
logger.info("[검증] Upbit API 키 유효성 확인 완료: 보유 자산 %d개", asset_count)
|
||||
|
||||
return True, "OK"
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
@@ -63,17 +103,60 @@ def validate_upbit_api_keys(access_key: str, secret_key: str) -> tuple[bool, str
|
||||
def adjust_price_to_tick_size(price: float) -> float:
|
||||
"""
|
||||
Upbit 호가 단위에 맞춰 가격을 조정합니다.
|
||||
pyupbit.get_tick_size를 사용하여 실시간 호가 단위를 가져옵니다.
|
||||
|
||||
- Decimal 기반으로 계산하여 부동소수점 오차를 최소화합니다.
|
||||
- pyupbit.get_tick_size 실패 시 원본 가격을 그대로 사용합니다.
|
||||
"""
|
||||
try:
|
||||
tick_size = pyupbit.get_tick_size(price)
|
||||
adjusted_price = round(price / tick_size) * tick_size
|
||||
return adjusted_price
|
||||
if not tick_size or tick_size <= 0:
|
||||
raise ValueError(f"invalid tick_size: {tick_size}")
|
||||
|
||||
d_price = Decimal(str(price))
|
||||
d_tick = Decimal(str(tick_size))
|
||||
|
||||
# 호가 단위에 가장 가까운 값으로 반올림 (최근접 호가)
|
||||
steps = (d_price / d_tick).to_integral_value(rounding=ROUND_HALF_UP)
|
||||
adjusted_price = steps * d_tick
|
||||
|
||||
return float(adjusted_price)
|
||||
except Exception as e:
|
||||
logger.warning("호가 단위 조정 실패: %s. 원본 가격 사용.", e)
|
||||
return price
|
||||
|
||||
|
||||
def compute_limit_order_params(amount_krw: float, raw_price: float) -> tuple[float, float]:
|
||||
"""
|
||||
지정가 매수 주문에 필요한 가격/수량을 Decimal로 안정적으로 계산합니다.
|
||||
|
||||
Args:
|
||||
amount_krw: 사용할 KRW 금액
|
||||
raw_price: 요청된 지정가 (슬리피지 반영 후 가격 등)
|
||||
|
||||
Returns:
|
||||
(adjusted_price, volume) where price respects tick size and volume is rounded down to 8 decimals.
|
||||
|
||||
Raises:
|
||||
ValueError: price 또는 amount가 유효하지 않을 때
|
||||
"""
|
||||
d_amount = Decimal(str(amount_krw))
|
||||
if d_amount <= 0:
|
||||
raise ValueError("amount_krw must be positive")
|
||||
|
||||
adjusted_price = adjust_price_to_tick_size(raw_price)
|
||||
d_price = Decimal(str(adjusted_price))
|
||||
if d_price <= 0:
|
||||
raise ValueError("price must be positive after tick adjustment")
|
||||
|
||||
# 수량은 호가 단위 가격에 맞춰 8자리로 내림 (초과 주문 방지)
|
||||
volume = (d_amount / d_price).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
|
||||
|
||||
if volume <= 0:
|
||||
raise ValueError("computed volume is non-positive")
|
||||
|
||||
return float(d_price), float(volume)
|
||||
|
||||
|
||||
def _make_confirm_token(length: int = 16) -> str:
|
||||
return secrets.token_hex(length)
|
||||
|
||||
@@ -84,18 +167,32 @@ _pending_order_lock = threading.Lock()
|
||||
def _write_pending_order(token: str, order: dict, pending_file: str = PENDING_ORDERS_FILE):
|
||||
with _pending_order_lock:
|
||||
try:
|
||||
now = time.time()
|
||||
ttl_seconds = PENDING_ORDER_TTL # 24h TTL for stale pending records
|
||||
|
||||
pending = []
|
||||
if os.path.exists(pending_file):
|
||||
with open(pending_file, encoding="utf-8") as f:
|
||||
try:
|
||||
pending = json.load(f)
|
||||
except Exception:
|
||||
except json.JSONDecodeError:
|
||||
pending = []
|
||||
pending.append({"token": token, "order": order, "timestamp": time.time()})
|
||||
with open(pending_file, "w", encoding="utf-8") as f:
|
||||
|
||||
# TTL cleanup
|
||||
pending = [p for p in pending if isinstance(p, dict) and (now - p.get("timestamp", now)) <= ttl_seconds]
|
||||
|
||||
pending.append({"token": token, "order": order, "timestamp": now})
|
||||
|
||||
os.makedirs(os.path.dirname(pending_file) or ".", exist_ok=True)
|
||||
temp_file = f"{pending_file}.tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(pending, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_file, pending_file)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.exception("pending_orders 기록 실패: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
_confirmation_lock = threading.Lock()
|
||||
@@ -232,10 +329,24 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
|
||||
logger.info("📋 진행 중인 주문 발견: %s (side=%s, volume=%.8f)", order.get("uuid"), side, volume)
|
||||
return order
|
||||
|
||||
# 2. Check done orders (filled) - 최근 주문부터 확인
|
||||
# 2. Check done orders (filled) - 최근 주문부터 확인 (타임스탬프 검증 추가)
|
||||
dones = upbit.get_orders(ticker=market, state="done", limit=5)
|
||||
if dones:
|
||||
for order in dones:
|
||||
# 타임스탬프 확인
|
||||
created_at = order.get("created_at")
|
||||
if created_at:
|
||||
try:
|
||||
# ISO 8601 파싱 (Upbit: 2018-04-10T15:42:23+09:00)
|
||||
# 파이썬 3.7+ fromisoformat 지원 (Z 처리 불완전할 수 있으나 Upbit는 +09:00)
|
||||
dt = datetime.fromisoformat(created_at)
|
||||
# 시간대 인지 (Offset Awareness) 처리
|
||||
now = datetime.now(dt.tzinfo)
|
||||
if (now - dt).total_seconds() > lookback_sec:
|
||||
continue # 제한 시간보다 오래된 주문은 무시
|
||||
except ValueError:
|
||||
pass # 날짜 파싱 실패 시 안전하게 무시 (하거나 로깅)
|
||||
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
if abs(float(order.get("volume")) - volume) > 1e-8:
|
||||
@@ -251,7 +362,7 @@ def _find_recent_order(upbit, market, side, volume, price=None, lookback_sec=60)
|
||||
return None
|
||||
|
||||
|
||||
def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
|
||||
def _has_duplicate_pending_order(upbit, market, side, volume, price=None, lookback_sec=120):
|
||||
"""
|
||||
Retry 전에 중복된 미체결/완료된 주문이 있는지 확인합니다.
|
||||
|
||||
@@ -287,6 +398,17 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
|
||||
done_orders = upbit.get_orders(ticker=market, state="done", limit=10)
|
||||
if done_orders:
|
||||
for order in done_orders:
|
||||
# 타임스탬프 확인 (Created At)
|
||||
created_at = order.get("created_at")
|
||||
if created_at:
|
||||
try:
|
||||
dt = datetime.fromisoformat(created_at)
|
||||
now = datetime.now(dt.tzinfo)
|
||||
if (now - dt).total_seconds() > lookback_sec:
|
||||
continue # 오래된 주문 무시
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if order.get("side") != side:
|
||||
continue
|
||||
order_vol = float(order.get("volume", 0))
|
||||
@@ -313,6 +435,8 @@ def _has_duplicate_pending_order(upbit, market, side, volume, price=None):
|
||||
def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
|
||||
|
||||
부분 매수 지원: 잔고가 부족하면 가능한 만큼 매수합니다.
|
||||
"""
|
||||
from .holdings import get_current_price
|
||||
|
||||
@@ -325,9 +449,14 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
allocation_token: str | None = None
|
||||
|
||||
try:
|
||||
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
|
||||
price = get_current_price(market)
|
||||
from .common import krw_balance_lock
|
||||
|
||||
with krw_balance_lock:
|
||||
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
|
||||
price = get_current_price(market)
|
||||
|
||||
# 현재가 검증
|
||||
if price <= 0:
|
||||
@@ -344,6 +473,57 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
"[WARNING] min_order_value_krw 설정 누락/비정상 -> 기본값 %d 사용 (raw=%s)", MIN_KRW_ORDER, raw_min
|
||||
)
|
||||
min_order_value = float(MIN_KRW_ORDER)
|
||||
|
||||
# ✅ 부분 매수 지원: 잔고 확인 및 조정 (CRITICAL-005)
|
||||
# ✅ Race Condition 방지: KRW 예산 할당 시스템 사용 (CRITICAL-v3-1 개선)
|
||||
if not cfg.dry_run:
|
||||
from .common import krw_budget_manager
|
||||
|
||||
success, allocated_amount, allocation_token = krw_budget_manager.allocate(
|
||||
market,
|
||||
amount_krw,
|
||||
upbit,
|
||||
min_order_value=min_order_value,
|
||||
)
|
||||
|
||||
if not success:
|
||||
msg = f"[매수 건너뜀] {market}\n사유: KRW 예산 부족\n요청 금액: {amount_krw:.0f} KRW"
|
||||
logger.warning(msg)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": amount_krw,
|
||||
"status": "skipped_insufficient_budget",
|
||||
"reason": "insufficient_budget",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
if allocated_amount < min_order_value:
|
||||
krw_budget_manager.release(allocation_token)
|
||||
msg = (
|
||||
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
|
||||
f"\n할당 금액: {allocated_amount:.0f} KRW < 최소 {min_order_value:.0f} KRW"
|
||||
)
|
||||
logger.warning(msg)
|
||||
return {
|
||||
"market": market,
|
||||
"side": "buy",
|
||||
"amount_krw": allocated_amount,
|
||||
"status": "skipped_insufficient_balance",
|
||||
"reason": "insufficient_balance",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
if allocated_amount < amount_krw:
|
||||
logger.info("[%s] KRW 예산 부분 할당: 요청 %.0f원 → 할당 %.0f원", market, amount_krw, allocated_amount)
|
||||
|
||||
amount_krw = allocated_amount
|
||||
|
||||
# 수수료 고려 (0.05%) - Decimal 기반으로 정밀 계산
|
||||
d_amount = Decimal(str(amount_krw))
|
||||
d_fee_rate = Decimal("0.0005") # 0.05% 수수료
|
||||
amount_krw = float(d_amount * (Decimal("1") - d_fee_rate))
|
||||
|
||||
if amount_krw < min_order_value:
|
||||
msg = (
|
||||
f"[매수 건너뜀] {market}\n사유: 최소 주문 금액 미만"
|
||||
@@ -359,7 +539,13 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
|
||||
# 슬리피지 적용 - Decimal 기반으로 정밀 계산
|
||||
if price > 0 and slippage_pct > 0:
|
||||
d_price = Decimal(str(price))
|
||||
d_slippage = Decimal(str(slippage_pct)) / Decimal("100")
|
||||
limit_price = float(d_price * (Decimal("1") + d_slippage))
|
||||
else:
|
||||
limit_price = price
|
||||
|
||||
if cfg.dry_run:
|
||||
logger.info(
|
||||
@@ -377,16 +563,12 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
resp = None
|
||||
|
||||
# Retry loop for robust order placement
|
||||
max_retries = 3
|
||||
max_retries = ORDER_MAX_RETRIES
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
if slippage_pct > 0 and limit_price > 0:
|
||||
# 지정가 매수
|
||||
adjusted_limit_price = adjust_price_to_tick_size(limit_price)
|
||||
volume = amount_krw / adjusted_limit_price
|
||||
|
||||
if adjusted_limit_price <= 0 or volume <= 0:
|
||||
raise ValueError(f"Invalid params: price={adjusted_limit_price}, volume={volume}")
|
||||
# 지정가 매수 (Decimal 기반 계산)
|
||||
adjusted_limit_price, volume = compute_limit_order_params(amount_krw, limit_price)
|
||||
|
||||
if attempt == 1:
|
||||
logger.info(
|
||||
@@ -426,7 +608,7 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
logger.warning("[매수 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
time.sleep(ORDER_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except requests.exceptions.ReadTimeout:
|
||||
@@ -455,11 +637,11 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
logger.warning("주문 확인 실패. 재시도합니다.")
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
time.sleep(ORDER_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
# Other exceptions (e.g. ValueError from pyupbit) - do not retry
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
||||
# Other expected exceptions (e.g. ValueError from pyupbit) - do not retry
|
||||
logger.error("[매수 실패] 예외 발생: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
@@ -515,11 +697,20 @@ def place_buy_order_upbit(market: str, amount_krw: float, cfg: RuntimeConfig) ->
|
||||
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
|
||||
except Exception:
|
||||
logger.debug("매수 주문 모니터링 중 예외 발생", exc_info=True)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
||||
logger.exception("Upbit 매수 주문 실패: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
finally:
|
||||
# 4. 주문 완료 후 예산 해제 (성공/실패 무관)
|
||||
if not cfg.dry_run:
|
||||
from .common import krw_budget_manager
|
||||
|
||||
krw_budget_manager.release(allocation_token)
|
||||
|
||||
|
||||
def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> dict:
|
||||
"""
|
||||
@@ -611,7 +802,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
|
||||
)
|
||||
|
||||
resp = None
|
||||
max_retries = 3
|
||||
max_retries = ORDER_MAX_RETRIES
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
resp = upbit.sell_market_order(market, amount)
|
||||
@@ -626,7 +817,7 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
|
||||
logger.warning("[매도 재시도] 연결 오류 (%d/%d): %s", attempt, max_retries, e)
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
time.sleep(ORDER_RETRY_DELAY)
|
||||
continue
|
||||
except requests.exceptions.ReadTimeout:
|
||||
logger.warning("[매도 확인] ReadTimeout 발생 (%d/%d). 주문 확인 시도...", attempt, max_retries)
|
||||
@@ -650,9 +841,9 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
|
||||
logger.warning("매도 주문 확인 실패. 재시도합니다.")
|
||||
if attempt == max_retries:
|
||||
raise
|
||||
time.sleep(1)
|
||||
time.sleep(ORDER_RETRY_DELAY)
|
||||
continue
|
||||
except Exception as e:
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
||||
logger.error("[매도 실패] 예외 발생: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
@@ -707,22 +898,49 @@ def place_sell_order_upbit(market: str, amount: float, cfg: RuntimeConfig) -> di
|
||||
except Exception:
|
||||
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
|
||||
return result
|
||||
except Exception as e:
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError, OSError) as e:
|
||||
logger.exception("Upbit 매도 주문 실패: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
|
||||
def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: RuntimeConfig) -> dict:
|
||||
def execute_sell_order_with_confirmation(
|
||||
symbol: str,
|
||||
amount: float,
|
||||
cfg: RuntimeConfig,
|
||||
reason: str = "",
|
||||
is_stop_loss: bool = False, # [NEW] 명시적 손절 플래그
|
||||
) -> dict:
|
||||
"""
|
||||
매도 주문 확인 후 실행
|
||||
|
||||
Args:
|
||||
symbol: 심볼
|
||||
amount: 수량
|
||||
cfg: 설정
|
||||
reason: 매도 사유 (예: "stop_loss", "profit_taking" 등)
|
||||
is_stop_loss: 손절 여부 (True면 확인 절차 스킵 가능)
|
||||
"""
|
||||
confirm_cfg = cfg.config.get("confirm", {})
|
||||
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
|
||||
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
|
||||
confirm_stop_loss = confirm_cfg.get("confirm_stop_loss", False)
|
||||
|
||||
result = None
|
||||
if not confirm_via_file:
|
||||
logger.info("파일 확인 비활성화: 즉시 매도 주문 실행")
|
||||
|
||||
# 즉시 매도 조건:
|
||||
# 1. 파일 확인 비활성화됨
|
||||
# 2. 또는 (손절이고 && 손절 확인이 비활성화됨)
|
||||
# is_stop_loss 플래그가 True이거나, reason 텍스트에 "stop_loss"/"손절"이 포함된 경우
|
||||
final_is_stop_loss = is_stop_loss or ("stop_loss" in reason) or ("손절" in reason)
|
||||
|
||||
bypass_confirmation = not confirm_via_file or (final_is_stop_loss and not confirm_stop_loss)
|
||||
|
||||
if bypass_confirmation:
|
||||
if final_is_stop_loss and confirm_via_file: # 파일 확인 모드인데 손절이라서 스킵하는 경우
|
||||
logger.info("손절(Stop Loss) 조건 발동: 파일 확인을 건너뛰고 즉시 매도합니다. (reason=%s)", reason)
|
||||
else:
|
||||
logger.info("파일 확인 비활성화(또는 조건): 즉시 매도 주문 실행")
|
||||
|
||||
result = place_sell_order_upbit(symbol, amount, cfg)
|
||||
else:
|
||||
token = _make_confirm_token()
|
||||
@@ -754,6 +972,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
|
||||
parse_mode=cfg.telegram_parse_mode,
|
||||
)
|
||||
|
||||
# 테스트 환경에서는 사용자 확인을 대기하지 않고 바로 반환하여 pytest 지연을 방지
|
||||
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
logger.info("[TEST] 확인 대기 생략: token=%s", token)
|
||||
return {"status": "user_not_confirmed", "symbol": symbol, "token": token, "timestamp": time.time()}
|
||||
|
||||
logger.info("[%s] 매도 확인 대기 중: 토큰=%s, 타임아웃=%d초", symbol, token, confirm_timeout)
|
||||
confirmed = _check_confirmation(token, confirm_timeout)
|
||||
|
||||
@@ -777,7 +1000,7 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
|
||||
if result and result.get("monitor"):
|
||||
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
|
||||
|
||||
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
|
||||
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
|
||||
if result:
|
||||
trade_status = result.get("status")
|
||||
monitor = result.get("monitor", {})
|
||||
@@ -798,7 +1021,11 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
|
||||
|
||||
record_trade(trade_record)
|
||||
|
||||
# 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
|
||||
# ✅ HIGH-008: 매도 후 재매수 방지 기록
|
||||
if trade_status in ["simulated", "filled"]:
|
||||
from .common import record_sell
|
||||
|
||||
record_sell(symbol) # 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
|
||||
if not cfg.dry_run and monitor:
|
||||
filled_volume = float(monitor.get("filled_volume", 0.0) or 0.0)
|
||||
final_status = monitor.get("final_status")
|
||||
@@ -811,7 +1038,9 @@ def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: Runtim
|
||||
return result
|
||||
|
||||
|
||||
def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: RuntimeConfig) -> dict:
|
||||
def execute_buy_order_with_confirmation(
|
||||
symbol: str, amount_krw: float, cfg: RuntimeConfig, indicators: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
|
||||
|
||||
@@ -819,6 +1048,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
|
||||
symbol: 거래 심볼
|
||||
amount_krw: 매수할 KRW 금액
|
||||
cfg: RuntimeConfig 객체
|
||||
indicators: (Optional) 지표 데이터 (백테스팅용)
|
||||
|
||||
Returns:
|
||||
주문 결과 딕셔너리
|
||||
@@ -904,7 +1134,7 @@ def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: Run
|
||||
}
|
||||
from .signals import record_trade
|
||||
|
||||
record_trade(trade_record)
|
||||
record_trade(trade_record, indicators=indicators)
|
||||
|
||||
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 holdings에 반영
|
||||
if not cfg.dry_run and monitor_result:
|
||||
|
||||
Reference in New Issue
Block a user