프로젝트/코인 투자 매크로

2025-06-25 [10] [v1.7] 현실 자산 거래

훈 님의 개발 블로그 2025. 6. 26. 02:01

오늘은 가상 자산이 아닌 현실 자산을 이용해서 거래를하는 기능을 추가해 보겠습니다!!
드디어 메모장에 적힌 숫자가 아닌 계좌에 적힌 숫자로 거래를 하게 됩니다 ㅎㅎ

계획은 다음과 같습니다
- main_trader.py에 기존 매매 동작을 class로 정의!
- 세부 내용 살포시 변경!
- is_virtual이라는 변수를 통해 가상 거래를 할지, 현실 거래를 할지 선택!!

그럼 시작해 보겠습니다.


1. 기존 기능 class화

1). class TradingBot()

# ----------------- 클래스 정의 시작 -----------------
class TradingBot:
    def __init__(self):
        """봇이 처음 생성될 때 실행되는 초기화 함수. 봇의 모든 상태 변수를 여기서 관리."""
        print("🤖 트레이딩 봇 초기화 시작...")

        # --- 봇 설정값 ---
        self.analysis_interval = "30m"
        self.check_interval_seconds = 30 * 60
        self.settle_count = 5  # 중간 정산 기준 거래 횟수
        self.consecutive_loss_limit = 5 # 연속 손실 한도
        self.cooldown_hours = 6 # 거래 중지 시간 (hour)

        # --- 봇 상태값 ---
        self.vportfolio = virtual_portfolio_manager.load_vportfolio()
        self.trade_stats = {"total_trades": 0, "wins": 0, "losses": 0, "total_gain": 0.0, "total_loss": 0.0}
        self.buy_rate = 0.1
        self.hodl_list = self.vportfolio.get("hodl_list", [])

        # --- 초기 자산 계산 ---
        self.initial_capital = self.vportfolio.get("cash", 1000000.0)
        for coin in self.vportfolio.get('coins_owned', []):
            self.initial_capital += coin["quantity"] * coin["avg_buy_price"]

        trade_history.set_trade_mode(is_virtual=True)

        # --- 봇 시작 메시지 ---
        print("=" * 50)
        print(f"🚀 가상 자동매매 봇을 시작합니다! (HODL 리스트: {self.hodl_list})")
        print(f"⏳ 분석 간격: {self.analysis_interval}, 체크 주기: {self.check_interval_seconds}초")
        print(f"💰 시작 총 자산 : {self.initial_capital:,.0f} KRW")
        print("봇을 중지하려면 Ctrl+C 를 누르세요.")
        print("=" * 50)

    def _filter_top_coins(self, top_n=20):
        """거래대금 상위 코인을 필터링하는 내부 메소드"""
        # 1. API를 통해 전체 코인 Ticker 정보를 가져옴
        # get_ticker_info 함수는 성공 시 'data' 딕셔너리를 반환함
        all_ticker_data = bithumb_api_client.get_ticker_info("ALL")

        # 2. API 호출 실패 또는 데이터 형식 오류 확인
        if not all_ticker_data or not isinstance(all_ticker_data, dict):
            print("⚠️  전체 Ticker 정보 조회에 실패하여 필터링을 건너뜁니다.")
            return []

        # 3. 데이터 처리
        coin_tickers = all_ticker_data.copy() # 원본 수정을 피하기 위해 복사
        if 'date' in coin_tickers:
            del coin_tickers['date']

        # 거래대금을 기준으로 코인들을 정렬하기 위해 리스트로 변환
        sorted_coins = []
        for symbol, data in coin_tickers.items():
            try:
                sorted_coins.append({'symbol': symbol, 'value': float(data.get('acc_trade_value_24H', 0))})
            except (ValueError, TypeError):
                continue

        # 거래대금을 기준으로 내림차순 정렬
        sorted_coins.sort(key=lambda x: x['value'], reverse=True)

        # 거래대금 상위 N개 코인 데이터 추출
        top_coins_data = sorted_coins[:top_n]
        target_coins = [coin['symbol'] for coin in top_coins_data]

        '''
        print("\n--- 💰 24시간 거래대금 상위 20개 관심 종목 ---")
        # 리스트가 비어있지 않을 때만 출력
        if top_coins_data:
            print("거래대금 상위 코인을 찾지 못했습니다.")
            for i, coin in enumerate(top_coins_data):
                print(f"{i+1:>2}. {coin['symbol']:<10} (거래대금: {coin['value']:>15,.0f} KRW)")
        else:
            print("거래대금 상위 코인을 찾지 못했습니다.")
        '''

        return target_coins

    def _check_cross_signal(self, df):
        """골든/데드 크로스를 판단하는 내부 메소드"""
        last_row, prev_row = df.iloc[-1], df.iloc[-2]
        sma5_today, sma20_today = last_row.get('SMA5'), last_row.get('SMA20')
        sma5_yesterday, sma20_yesterday = prev_row.get('SMA5'), prev_row.get('SMA20')

        if None in [sma5_today, sma20_today, sma5_yesterday, sma20_yesterday]: return "HOLD"

        if sma5_yesterday < sma20_yesterday and sma5_today > sma20_today:
            return "BUY"
        elif sma5_yesterday > sma20_yesterday and sma5_today < sma20_today:
            return "SELL"
        else:
            return "HOLD"

    def _check_and_execute_sell_strategy(self, coin_symbol, owned_coin_info):
        """보유 코인에 대한 매도 전략을 확인하고 실행합니다."""
        ticker = bithumb_api_client.get_ticker_info(coin_symbol)
        if not ticker:
            print(f"-> [{coin_symbol}] 현재가 조회 실패. 매도 판단을 건너뜁니다.")
            return

        current_price = float(ticker['closing_price'])
        avg_buy_price = owned_coin_info['avg_buy_price']
        profit_rate = (current_price - avg_buy_price) / avg_buy_price if avg_buy_price > 0 else 0

        pt_rate = self.vportfolio['strategy_params']['profit_take_percentage']
        sl_rate = self.vportfolio['strategy_params']['stop_loss_percentage']

        signal = "HOLD"
        candlestick_data = bithumb_api_client.get_candlestick_data(coin_symbol, chart_intervals=self.analysis_interval)
        if candlestick_data and len(candlestick_data) > 20:
            df = pd.DataFrame(candlestick_data, columns=['timestamp', 'open', 'close', 'high', 'low', 'volume'])
            df['close'] = pd.to_numeric(df['close'])
            df['SMA5'] = technical_analyzer.calculate_sma(candlestick_data, period=5)
            df['SMA20'] = technical_analyzer.calculate_sma(candlestick_data, period=20)
            signal = self._check_cross_signal(df)

        print(f"-> [{coin_symbol}] 보유 중. 기술적 신호: {signal}  (현재 수익률: {profit_rate:+.2%}) ")

        is_profit_take = profit_rate >= pt_rate
        is_stop_loss = profit_rate <= -sl_rate
        is_dead_cross = signal == "SELL"

        should_sell = False
        sell_reason = ""
        quantity_to_sell = 0
        if is_profit_take:
            sell_reason = "profit_take"
            quantity_to_sell = owned_coin_info['quantity']  # 전량 매도
            print(f"\t-> [{coin_symbol}] 💰 수익실현 조건 달성! 전량 매도를 시도합니다.")
            should_sell = True
        elif is_stop_loss:
            sell_reason = "stop_loss"
            quantity_to_sell = owned_coin_info['quantity']  # 전량 매도
            print(f"\t-> [{coin_symbol}] 🛡️ 손절매 조건 달성! 전량 매도를 시도합니다.")
            should_sell = True
        elif is_dead_cross:
            # 데드크로스는 추세 전환 신호이므로, 일단 절반만 팔아서 리스크를 줄이는 아이디어 적용!
            sell_reason = "dead_cross"
            quantity_to_sell = owned_coin_info['quantity'] / 2  # 50% 매도
            print(f"\t-> [{coin_symbol}] 🔴 데드 크로스 발생! 50% 매도를 시도합니다.")
            should_sell = True

            # --- 3. 매도 계획이 세워졌다면, 최종적으로 딱 한 번만 실행한다 ---
        if should_sell:
            profit = (current_price - avg_buy_price) * quantity_to_sell

            # --- 👇 연속 손실 횟수 업데이트 로직 추가! 👇 ---
            if profit >= 0: # 수익이거나 본전일 때
                # 수익이 나면 연속 손실 횟수를 0으로 리셋
                self.vportfolio["consecutive_losses"] = 0
                print(f"-> 👍 수익 발생. 연속 손실 횟수가 0으로 초기화됩니다.")
            else: # 손실일 때
                # 손실이 나면 연속 손실 횟수를 1 증가
                self.vportfolio["consecutive_losses"] += 1
                print(f"-> 👎 손실 발생. 연속 손실 횟수 +1 (현재: {self.vportfolio['consecutive_losses']}회)")

                # --- 연속 손실 한도 도달 시 쿨다운 설정! ---
                if self.vportfolio["consecutive_losses"] >= self.consecutive_loss_limit:
                    cooldown_end_time = time.time() + self.cooldown_hours * 3600    # hour
                    self.vportfolio["cooldown_until"] = cooldown_end_time
                    print(f"🔥🔥🔥 연속 손실 한도({self.consecutive_loss_limit}회) 도달! {self.cooldown_hours}시간 동안 거래를 중지합니다.")
                    print(f"-> 거래 재개 예정 시각: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(cooldown_end_time))}")

            # 거래 통계 업데이트
            if sell_reason == "profit_take":
                self.trade_stats["wins"] += 1; self.trade_stats["total_gain"] += profit
            elif sell_reason == "stop_loss":
                self.trade_stats["losses"] += 1; self.trade_stats["total_loss"] += profit
            elif sell_reason == "dead_cross":
                if profit > 0:
                    self.trade_stats["wins"] += 1; self.trade_stats["total_gain"] += profit
                else:
                    self.trade_stats["losses"] += 1; self.trade_stats["total_loss"] += profit
            self.trade_stats["total_trades"] += 1

            # 가상 매도 실행
            updated_portfolio = virtual_portfolio_manager.record_vportfolio_trade(
                self.vportfolio, "sell", coin_symbol, quantity_to_sell, current_price,
                reason=sell_reason, profit_rate=profit_rate
            )
            if updated_portfolio: self.vportfolio = updated_portfolio

    def _check_and_execute_buy_strategy(self, coin_symbol):
        """미보유 코인에 대한 매수 전략을 확인하고 실행합니다."""
        candlestick_data = bithumb_api_client.get_candlestick_data(coin_symbol, chart_intervals=self.analysis_interval)
        signal = "HOLD"

        if candlestick_data and len(candlestick_data) > 20:
            df = pd.DataFrame(candlestick_data, columns=['timestamp', 'open', 'close', 'high', 'low', 'volume'])
            df['close'] = pd.to_numeric(df['close'])
            df['SMA5'] = technical_analyzer.calculate_sma(candlestick_data, period=5)
            df['SMA20'] = technical_analyzer.calculate_sma(candlestick_data, period=20)
            signal = self._check_cross_signal(df)

        print(f"-> [{coin_symbol}] 미보유. 기술적 신호: {signal}")

        if signal == "BUY":
            print(f"\t-> [{coin_symbol}] 🟢 골든 크로스 발생! 신규 매수를 시도합니다.")
            cash_to_invest = self.vportfolio['cash'] * self.buy_rate
            ticker = bithumb_api_client.get_ticker_info(coin_symbol)
            if ticker:
                current_price = float(ticker['closing_price'])
                quantity_to_buy = cash_to_invest / current_price if current_price > 0 else 0

                updated_portfolio = virtual_portfolio_manager.record_vportfolio_trade(
                    self.vportfolio, "buy", coin_symbol, quantity_to_buy, current_price
                )
                if updated_portfolio: self.vportfolio = updated_portfolio

    def _check_and_settle(self):
        """중간 정산 로직"""
        if self.trade_stats["total_trades"] >= self.settle_count:
            odds = self.trade_stats["wins"] / self.trade_stats["total_trades"]
            print("=" * 50)
            print(f"⭐[중간 정산] 최근 {self.trade_stats['total_trades']}회 거래 실적⭐")
            print(f"승리 횟수: {self.trade_stats['wins']} / 총 수익 금액: {self.trade_stats['total_gain']:,.0f} KRW")
            print(f"패배 횟수: {self.trade_stats['losses']} / 총 손실 금액: {self.trade_stats['total_loss']:,.0f} KRW")
            print(f"승률 : {odds:.2%}")

            # 승률에 따른 매수 비율 조정
            if odds >= 0.6 and self.buy_rate < 0.5:
                old_buy_rate = self.buy_rate
                self.buy_rate += 0.1
                print(f"  --> 높은 승률로 매수 비율이 상승🔼하였습니다. [ {old_buy_rate:.1%} --> {self.buy_rate:.1%}]")
            elif odds <= 0.4 and self.buy_rate > 0.1:
                old_buy_rate = self.buy_rate
                self.buy_rate -= 0.1
                print(f"  --> 낮은 승률로 매수 비율이 하락🔽하였습니다. [ {old_buy_rate:.1%} --> {self.buy_rate:.1%}]")
            print("=" * 50)

            # 정산 후 통계 초기화
            self.trade_stats = {"total_trades": 0, "wins": 0, "losses": 0, "total_gain": 0.0, "total_loss": 0.0}

    def _print_total_assets(self):
        """총 자산 현황 출력 로직"""
        print("\n" + "=" * 20 + " ⚖️ 포트폴리오 현황 ⚖️ " + "=" * 20)

        total_coin_value = 0.0
        for coin in self.vportfolio.get('coins_owned', []):
            ticker = bithumb_api_client.get_ticker_info(coin["symbol"])
            if ticker:
                current_price = float(ticker['closing_price'])
                coin_value = coin["quantity"] * current_price
                total_coin_value += coin_value
                if coin['avg_buy_price'] < 10.0 or current_price < 10.0:
                    print(f"  - 보유: {coin['symbol']:<8} 수량: {coin['quantity']:<15.4f} 평단가: {coin['avg_buy_price']:>12,.4f} KRW   |   현재가: {current_price:>12,.4f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(coin['avg_buy_price']-current_price)/coin['avg_buy_price']:>6,.1%}")
                else:
                    print(f"  - 보유: {coin['symbol']:<8} 수량: {coin['quantity']:<15.4f} 평단가: {coin['avg_buy_price']:>12,.0f} KRW   |   현재가: {current_price:>12,.0f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(coin['avg_buy_price']-current_price)/coin['avg_buy_price']:>6,.1%}")

        current_cash = self.vportfolio.get("cash", 0.0)
        total_assets = current_cash + total_coin_value
        pnl_percentage = ((total_assets - self.initial_capital) / self.initial_capital) * 100 if self.initial_capital > 0 else 0

        print(f"  - 현금: {current_cash:,.0f} KRW")
        print("-" * 62)
        print(f"  - 총 자산: {total_assets:,.0f} KRW (초기 자산 대비: {pnl_percentage:+.2f}%)")
        print("=" * 62)

    def run_one_cycle(self):
        """한 번의 분석/거래 사이클 전체를 지휘하는 메인 메소드"""
        # --- 쿨다운(거래 중지) 상태인지 먼저 확인! ---
        is_in_cooldown = False
        cooldown_until = self.vportfolio.get("cooldown_until", 0)
        if time.time() < cooldown_until:
            is_in_cooldown = True
            resume_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(cooldown_until))
            print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] ❄️ 거래 중지(쿨다운) 상태입니다. ({resume_time_str} 까지)")
        elif cooldown_until > 0:
            # 쿨다운 시간이 지났다면, 상태를 초기화
            print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] ✅ 쿨다운 기간이 종료되었습니다. 거래를 재개합니다.")
            self.vportfolio["consecutive_losses"] = 0
            self.vportfolio["cooldown_until"] = 0
            virtual_portfolio_manager.save_vportfolio(self.vportfolio)  # 상태 초기화 후 저장

        # 1. 분석 대상 선정
        top_coins = self._filter_top_coins()
        owned_symbols = [coin['symbol'] for coin in self.vportfolio.get('coins_owned', [])]
        combined_list = list(set(top_coins + owned_symbols))
        final_analysis_list = [coin for coin in combined_list if coin not in self.hodl_list]

        print(f"\n--- 최종 분석 대상 ({len(final_analysis_list)}개) ---")
        print(final_analysis_list)
        print("-" * 50)

        # 2. 각 코인 분석 및 거래 실행
        for coin_symbol in final_analysis_list:
            owned_coin_info = next((coin for coin in self.vportfolio["coins_owned"] if coin["symbol"] == coin_symbol), None)
            if owned_coin_info:
                self._check_and_execute_sell_strategy(coin_symbol, owned_coin_info)
            else:
                # 쿨다운 중에는 매수 중지
                if is_in_cooldown:
                    continue
                self._check_and_execute_buy_strategy(coin_symbol)

        # 3. 사이클 종료 후 중간 정산 및 자산 현황 출력
        self._check_and_settle()
        self._print_total_assets()

2). main 변경

# --- 메인 실행 부분 ---
if __name__ == "__main__":
    my_bot = TradingBot()
    while True: # 실제 운영 시 주석 해제
        try:
            my_bot.run_one_cycle()
            time.sleep(my_bot.check_interval_seconds) # 실제 운영 시 주석 해제
        except KeyboardInterrupt:
            print("\n\n사용자에 의해 프로그램이 중단되었습니다. 최종 포트폴리오 상태를 저장합니다...")
            virtual_portfolio_manager.save_vportfolio(my_bot.vportfolio)
            print("프로그램을 종료합니다.")
            break
        except Exception as e:
            print(f"⚠️ 예상치 못한 오류 발생: {e}")
            print(f"{my_bot.check_interval_seconds}초 후 재시도합니다...")
            time.sleep(my_bot.check_interval_seconds)

 

2. 현실 자산 거래 함수 추가

현실 자산 거래를 위해 bithumb_api_client.py에 다음 함수를 추가해줍니다.

1). import 추가

import hashlib
from urllib.parse import urlencode

2). 헤더 생성 함수

# --- Private API 호출을 위한 헬퍼(Helper) 함수 ---
"""
Private API 요청을 위한 인증 헤더(JWT)를 생성
:endpoint: 요청할 엔드포인트 (예: "/v1/accounts")
:params: POST 요청 시 body에 포함될 파라미터 딕셔너리
:return: 인증 헤더 딕셔너리
"""
def _get_v1_auth_headers(params_for_hash={}):
    """v1 Private API 요청을 위한 JWT 인증 헤더를 생성합니다."""
    payload = {
        'access_key': api_key,
        'nonce': str(uuid.uuid4()),
        'timestamp': str(round(time.time() * 1000)),
    }

    if params_for_hash:
        query_string = urlencode(params_for_hash)
        hasher = hashlib.sha512()
        hasher.update(query_string.encode('utf-8'))
        query_hash = hasher.hexdigest()

        payload['query_hash'] = query_hash
        payload['query_hash_alg'] = 'SHA512'

    jwt_token = jwt.encode(payload, api_secret, algorithm="HS256")

    return {
        'Authorization': f'Bearer {jwt_token}',
        'Content-Type': 'application/json'
    }

3). 실제 매수/매도 함수

전체 매도를 진행하면 수수료 에러가 발생하는 경우가 있어서 dust를 남기게 되지만 (개수 / 1.0025)만큼만 팔 수 있게 설정했어요.
그리고 최소 주문 금액이 5천원 인것도 꼭 기억해야 합니다!
이거 계속 테스트하느라 수수료 계속 날아가 버렸네요ㅠㅠ

"""
v1 API로 실제 매수/매도 주문 (Private API)
:side: 'buy' 또는 'sell'
:ord_type: 'limit', 'market_buy', 'market_sell'
:units: 주문 수량 (코인 개수)
:price: 주문 가격 또는 총액 (KRW)
"""
def place_order(order_currency, side, ord_type, units=None, price=None, payment_currency="KRW"):
    if not api_key or not api_secret: return None

    endpoint = "/v1/orders"
    url = BITHUMB_API_URL + endpoint
    request_body = {
        "market": f"{payment_currency.upper()}-{order_currency.upper()}",
        "side": "bid" if side.lower() == "buy" else "ask",
    }

    # 수수료때문에 매도가 되지 않는 문제 해결
    if side.lower() == "sell" and units is not None:
        fee_rate = 0.0025
        units = units / (1.0 + fee_rate)

    ord_type_lower = ord_type.lower()
    if ord_type_lower == 'limit':
        if units is None or price is None: print("🔥 지정가 주문에는 units와 price가 모두 필요합니다."); return None
        request_body['ord_type'] = 'limit';
        request_body['volume'] = f"{units:.4f}";
        request_body['price'] = str(price)
    elif ord_type_lower == 'market_buy':
        if price is None: print("🔥 시장가 매수에는 price(총 주문금액)가 필요합니다."); return None
        request_body['ord_type'] = 'price';
        request_body['price'] = str(price)
    elif ord_type_lower == 'market_sell':
        if units is None: print("🔥 시장가 매도에는 units(수량)가 필요합니다."); return None
        request_body['ord_type'] = 'market';
        request_body['volume'] = f"{units:.4f}"
    else:
        print(f"🔥 지원하지 않는 주문 유형입니다: {ord_type}");
        return None

    try:
        headers = _get_v1_auth_headers(request_body)
        response = requests.post(url, headers=headers, data=json.dumps(request_body))
        response.raise_for_status()
        result = response.json()

        if isinstance(result, dict) and 'uuid' in result:
            order_id = result.get('uuid')
            side_kor = "매수" if request_body["side"] == "bid" else "매도"
            print(f"✅ [실제 주문 성공] {order_currency} {side_kor} 주문이 접수되었습니다. (주문 ID: {order_id})")
            return result
        else:
            # 실패 응답은 보통 'code'와 'message'를 포함함
            print(f"🔥 [실제 주문 실패] API 에러: {result.get('message')} (코드: {result.get('code')})")
            return None

    except requests.exceptions.HTTPError as e:
        print(f"주문 중 에러 발생: {e.response.status_code} - {e.response.text}")
        return None
    except Exception as e:
        print(f"주문 중 에러 발생: {e}")
        return None

4). 가상 거래에 수수료 추가

겸사겸사 가상 거래에도 수수료를 추가해줍니다.
virtual_portfolio_manager.py의 record_vportfolio_trade함수 변경

def record_vportfolio_trade(portfolio_data, trade_type, symbol, quantity, price_per_unit, reason=None, profit_rate=0.0):
    symbol_upper = symbol.upper()
    total_value = quantity * price_per_unit

    # --- 👇 현실적인 수수료 계산 로직 추가! 👇 ---
    fee_rate = 0.0025  # 빗썸 수수료율 0.25%
    trade_fee = total_value * fee_rate
    # --- 👆 여기까지 👆 ---

    trade_status_message = ""

    if trade_type.lower() == "buy":
        # --- 👇 매수 시, (거래대금 + 수수료) 만큼 현금 차감 👇 ---
        required_cash = total_value + trade_fee
        if portfolio_data["cash"] < required_cash:
            print(f"🔥 [가상 매수 실패] 현금 부족. (필요: {required_cash:,.0f} KRW, 보유: {portfolio_data['cash']:,.0f} KRW)")
            return None  # 거래 실패

        portfolio_data["cash"] -= required_cash
        # --- 👆 여기까지 👆 ---

        # 보유 코인 목록 업데이트
        owned = False
        for coin in portfolio_data["coins_owned"]:
            if coin["symbol"] == symbol_upper:
                # 이미 보유한 코인이면, 평단가 재계산 (이동평균법)
                current_total_value = coin["quantity"] * coin["avg_buy_price"]
                new_total_value = current_total_value + total_value
                new_total_quantity = coin["quantity"] + quantity

                coin["avg_buy_price"] = new_total_value / new_total_quantity
                coin["quantity"] = new_total_quantity
                owned = True
                break
        if not owned:
            portfolio_data["coins_owned"].append({
                "symbol": symbol_upper, "quantity": quantity, "avg_buy_price": price_per_unit
            })
        trade_status_message = f"✅ [가상 매수 성공] {symbol_upper} {quantity:.4f}개 매수. (수수료: {trade_fee:,.2f} KRW 차감)"

    elif trade_type.lower() == "sell":
        owned_coin = next((c for c in portfolio_data["coins_owned"] if c["symbol"] == symbol_upper), None)
        if not owned_coin or owned_coin["quantity"] < quantity:
            print(
                f"🔥 [가상 매도 실패] 보유 수량 부족. (판매 시도: {quantity:.4f}, 보유: {owned_coin['quantity'] if owned_coin else 0:.4f})")
            return None

        # --- 👇 매도 시, 거래대금에서 수수료를 뺀 만큼만 현금 증가 👇 ---
        portfolio_data["cash"] += (total_value - trade_fee)
        # --- 👆 여기까지 👆 ---

        owned_coin["quantity"] -= quantity
        if owned_coin["quantity"] <= 1e-8:  # 아주 작은 수량은 0으로 처리하고 목록에서 제거
            portfolio_data["coins_owned"] = [c for c in portfolio_data["coins_owned"] if c["symbol"] != symbol_upper]
        trade_status_message = f"✅ [가상 매도 성공] {symbol_upper} {quantity:.4f}개 매도. (수수료: {trade_fee:,.2f} KRW 차감)"

    print(trade_status_message)

    # 거래 내역 생성 및 기록
    trade_info = {
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "type": trade_type.lower(), "symbol": symbol_upper,
        "quantity": quantity, "price_per_unit": price_per_unit, "total_value": total_value,
        "fee": trade_fee, "cash_after_trade": portfolio_data["cash"],
        "reason": reason, "profit_rate": profit_rate
    }
    trade_history.add_trade_to_history(trade_info)
    save_vportfolio(portfolio_data)

    return portfolio_data

 

3. main_trader.py 변경

main_trader.py는 바꿀게 정말 많아요!
그래서 작업하다가 어디어디 작업했는지 까먹어버렸습니다!!ㅠㅠ
class 통소스 올릴테니 공부하실분은 열심히 분석해주세요

class TradingBot:
    def __init__(self, is_virtual = True):
        """봇이 처음 생성될 때 실행되는 초기화 함수. 봇의 모든 상태 변수를 여기서 관리."""
        print("🤖 트레이딩 봇 초기화 시작...")

        # --- 봇 설정값 ---
        self.is_virtual_mode = is_virtual
        self.analysis_interval = "30m"
        self.check_interval_seconds = 30 * 60
        self.settle_count = 5  # 중간 정산 기준 거래 횟수
        self.consecutive_loss_limit = 5 # 연속 손실 한도
        self.cooldown_hours = 6 # 거래 중지 시간 (hour)

        # --- 봇 상태값 ---
        self.vportfolio = virtual_portfolio_manager.load_vportfolio()
        self.trade_stats = {"total_trades": 0, "wins": 0, "losses": 0, "total_gain": 0.0, "total_loss": 0.0}
        self.buy_rate = 0.1
        self.hodl_list = self.vportfolio.get("hodl_list", [])

        # --- 초기 자산 계산 ---
        self.initial_capital = 0.0
        self._initialize_capital()

        trade_history.set_trade_mode(is_virtual=self.is_virtual_mode)

        # --- 봇 시작 메시지 ---
        print("=" * 50)
        print(f"🚀 가상 자동매매 봇을 시작합니다! (HODL 리스트: {self.hodl_list})")
        print(f"⏳ 분석 간격: {self.analysis_interval}, 체크 주기: {self.check_interval_seconds}초")
        print(f"💰 시작 총 자산 : {self.initial_capital:,.0f} KRW")
        print("봇을 중지하려면 Ctrl+C 를 누르세요.")
        print("=" * 50)

    def _initialize_capital(self):
        if self.is_virtual_mode:
            print("⚖ 가상 포트폴리오를 기준으로 초기 자산을 계산합니다 ⚖")
            self.initial_capital = self.vportfolio.get("cash", 1000000.0)
            for coin in self.vportfolio.get('coins_owned', []):
                self.initial_capital += coin["quantity"] * coin["avg_buy_price"]
        else:
            print("⚖ 실제 계좌 정보을 기준으로 초기 자산을 계산합니다 ⚖")
            real_account_info = bithumb_api_client.get_account_info()
            if isinstance(real_account_info, list):
                for asset in real_account_info:
                    currency = asset.get('currency')
                    balance = float(asset.get('balance', 0))
                    if currency == 'KRW':
                        self.initial_capital += balance
                    else:
                        # 코인은 평단가 * 수량으로 가치 계산
                        avg_buy_price = float(asset.get('avg_buy_price', 0))
                        self.initial_capital += balance * avg_buy_price
            else:
                print("🔥 실제 계좌 정보를 가져올 수 없어 초기 자산을 0으로 설정합니다.")

    def _filter_top_coins(self, top_n=20):
        """거래대금 상위 코인을 필터링하는 내부 메소드"""
        # 1. API를 통해 전체 코인 Ticker 정보를 가져옴
        # get_ticker_info 함수는 성공 시 'data' 딕셔너리를 반환함
        all_ticker_data = bithumb_api_client.get_ticker_info("ALL")

        # 2. API 호출 실패 또는 데이터 형식 오류 확인
        if not all_ticker_data or not isinstance(all_ticker_data, dict):
            print("⚠️  전체 Ticker 정보 조회에 실패하여 필터링을 건너뜁니다.")
            return []

        # 3. 데이터 처리
        coin_tickers = all_ticker_data.copy() # 원본 수정을 피하기 위해 복사
        if 'date' in coin_tickers:
            del coin_tickers['date']

        # 거래대금을 기준으로 코인들을 정렬하기 위해 리스트로 변환
        sorted_coins = []
        for symbol, data in coin_tickers.items():
            try:
                sorted_coins.append({'symbol': symbol, 'value': float(data.get('acc_trade_value_24H', 0))})
            except (ValueError, TypeError):
                continue

        # 거래대금을 기준으로 내림차순 정렬
        sorted_coins.sort(key=lambda x: x['value'], reverse=True)

        # 거래대금 상위 N개 코인 데이터 추출
        top_coins_data = sorted_coins[:top_n]
        target_coins = [coin['symbol'] for coin in top_coins_data]

        '''
        print("\n--- 💰 24시간 거래대금 상위 20개 관심 종목 ---")
        # 리스트가 비어있지 않을 때만 출력
        if top_coins_data:
            print("거래대금 상위 코인을 찾지 못했습니다.")
            for i, coin in enumerate(top_coins_data):
                print(f"{i+1:>2}. {coin['symbol']:<10} (거래대금: {coin['value']:>15,.0f} KRW)")
        else:
            print("거래대금 상위 코인을 찾지 못했습니다.")
        '''

        return target_coins

    def _check_cross_signal(self, df):
        """골든/데드 크로스를 판단하는 내부 메소드"""
        last_row, prev_row = df.iloc[-1], df.iloc[-2]
        sma5_today, sma20_today = last_row.get('SMA5'), last_row.get('SMA20')
        sma5_yesterday, sma20_yesterday = prev_row.get('SMA5'), prev_row.get('SMA20')

        if None in [sma5_today, sma20_today, sma5_yesterday, sma20_yesterday]: return "HOLD"

        if sma5_yesterday < sma20_yesterday and sma5_today > sma20_today:
            return "BUY"
        elif sma5_yesterday > sma20_yesterday and sma5_today < sma20_today:
            return "SELL"
        else:
            return "HOLD"

    def _check_and_execute_sell_strategy(self, coin_symbol, owned_coin_info):
        """보유 코인에 대한 매도 전략을 확인하고 실행합니다."""
        ticker = bithumb_api_client.get_ticker_info(coin_symbol)
        if not ticker:
            print(f"-> [{coin_symbol}] 현재가 조회 실패. 매도 판단을 건너뜁니다.")
            return

        current_price = float(ticker['closing_price'])
        avg_buy_price = owned_coin_info['avg_buy_price']
        profit_rate = (current_price - avg_buy_price) / avg_buy_price if avg_buy_price > 0 else 0

        pt_rate = self.vportfolio['strategy_params']['profit_take_percentage']
        sl_rate = self.vportfolio['strategy_params']['stop_loss_percentage']

        signal = "HOLD"
        candlestick_data = bithumb_api_client.get_candlestick_data(coin_symbol, chart_intervals=self.analysis_interval)
        if candlestick_data and len(candlestick_data) > 20:
            df = pd.DataFrame(candlestick_data, columns=['timestamp', 'open', 'close', 'high', 'low', 'volume'])
            df['close'] = pd.to_numeric(df['close'])
            df['SMA5'] = technical_analyzer.calculate_sma(candlestick_data, period=5)
            df['SMA20'] = technical_analyzer.calculate_sma(candlestick_data, period=20)
            signal = self._check_cross_signal(df)

        print(f"-> [{coin_symbol}] 보유 중. 기술적 신호: {signal}  (현재 수익률: {profit_rate:+.2%}) ")

        is_profit_take = profit_rate >= pt_rate
        is_stop_loss = profit_rate <= -sl_rate
        is_dead_cross = signal == "SELL"

        should_sell = False
        sell_reason = ""
        quantity_to_sell = 0
        if is_profit_take:
            sell_reason = "profit_take"
            quantity_to_sell = owned_coin_info['quantity']  # 전량 매도
            print(f"\t-> [{coin_symbol}] 💰 수익실현 조건 달성! 전량 매도를 시도합니다.")
            should_sell = True
        elif is_stop_loss:
            sell_reason = "stop_loss"
            quantity_to_sell = owned_coin_info['quantity']  # 전량 매도
            print(f"\t-> [{coin_symbol}] 🛡️ 손절매 조건 달성! 전량 매도를 시도합니다.")
            should_sell = True
        elif is_dead_cross:
            # 데드크로스는 추세 전환 신호이므로, 일단 절반만 팔아서 리스크를 줄이는 아이디어 적용!
            sell_reason = "dead_cross"
            quantity_to_sell = owned_coin_info['quantity'] / 2  # 50% 매도
            print(f"\t-> [{coin_symbol}] 🔴 데드 크로스 발생! 50% 매도를 시도합니다.")
            should_sell = True

            # --- 3. 매도 계획이 세워졌다면, 최종적으로 딱 한 번만 실행한다 ---
        if should_sell:
            profit = (current_price - avg_buy_price) * quantity_to_sell

            # --- 👇 연속 손실 횟수 업데이트 로직 추가! 👇 ---
            if profit >= 0: # 수익이거나 본전일 때
                # 수익이 나면 연속 손실 횟수를 0으로 리셋
                self.vportfolio["consecutive_losses"] = 0
                print(f"-> 👍 수익 발생. 연속 손실 횟수가 0으로 초기화됩니다.")
            else: # 손실일 때
                # 손실이 나면 연속 손실 횟수를 1 증가
                self.vportfolio["consecutive_losses"] += 1
                print(f"-> 👎 손실 발생. 연속 손실 횟수 +1 (현재: {self.vportfolio['consecutive_losses']}회)")

                # --- 연속 손실 한도 도달 시 쿨다운 설정! ---
                if self.vportfolio["consecutive_losses"] >= self.consecutive_loss_limit:
                    cooldown_end_time = time.time() + self.cooldown_hours * 3600    # hour
                    self.vportfolio["cooldown_until"] = cooldown_end_time
                    print(f"🔥🔥🔥 연속 손실 한도({self.consecutive_loss_limit}회) 도달! {self.cooldown_hours}시간 동안 거래를 중지합니다.")
                    print(f"-> 거래 재개 예정 시각: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(cooldown_end_time))}")

            # 거래 통계 업데이트
            if sell_reason == "profit_take":
                self.trade_stats["wins"] += 1; self.trade_stats["total_gain"] += profit
            elif sell_reason == "stop_loss":
                self.trade_stats["losses"] += 1; self.trade_stats["total_loss"] += profit
            elif sell_reason == "dead_cross":
                if profit > 0:
                    self.trade_stats["wins"] += 1; self.trade_stats["total_gain"] += profit
                else:
                    self.trade_stats["losses"] += 1; self.trade_stats["total_loss"] += profit
            self.trade_stats["total_trades"] += 1

            # 가상 매도 실행
            updated_portfolio = virtual_portfolio_manager.record_vportfolio_trade(
                self.vportfolio, "sell", coin_symbol, quantity_to_sell, current_price,
                reason=sell_reason, profit_rate=profit_rate
            )
            if updated_portfolio: self.vportfolio = updated_portfolio

    def _check_and_execute_buy_strategy(self, coin_symbol):
        """미보유 코인에 대한 매수 전략을 확인하고 실행합니다."""
        candlestick_data = bithumb_api_client.get_candlestick_data(coin_symbol, chart_intervals=self.analysis_interval)
        signal = "HOLD"

        if candlestick_data and len(candlestick_data) > 20:
            df = pd.DataFrame(candlestick_data, columns=['timestamp', 'open', 'close', 'high', 'low', 'volume'])
            df['close'] = pd.to_numeric(df['close'])
            df['SMA5'] = technical_analyzer.calculate_sma(candlestick_data, period=5)
            df['SMA20'] = technical_analyzer.calculate_sma(candlestick_data, period=20)
            signal = self._check_cross_signal(df)

        print(f"-> [{coin_symbol}] 미보유. 기술적 신호: {signal}")

        if signal == "BUY":
            print(f"\t-> [{coin_symbol}] 🟢 골든 크로스 발생! 신규 매수를 시도합니다.")
            available_cash = self.vportfolio['cash']
            min_order_krw = 5000  # 최소 주문 금액
            fee_rate = 0.0025  # 수수료율
            cash_to_invest = 0

            if available_cash < min_order_krw:
                print(f"\t-> 보유 현금({available_cash:,.0f}원)이 최소 주문 금액({min_order_krw:,.0f}원)보다 적어 매수를 건너뜁니다.")
                return

                # 보유 현금이 10,000원 이하면 전액 매수 시도
            if available_cash <= 10000:
                print(f"\t-> 보유 현금이 적어 전액 매수를 시도합니다.")
                cash_to_invest = available_cash
            else:
                # 기본적으로는 설정된 비율(buy_rate)만큼 투자
                intended_investment = available_cash * self.buy_rate

                # 다만, 계산된 금액이 최소 주문 금액보다 작으면, 최소 주문 금액으로 투자
                if intended_investment < min_order_krw:
                    print(f"\t-> 비율 투자금({intended_investment:,.0f}원)이 최소 금액보다 작아, 최소 금액({min_order_krw:,.0f}원)으로 투자를 시도합니다.")
                    cash_to_invest = min_order_krw
                else:
                    cash_to_invest = intended_investment

            # --- 4. 최종 투자 금액으로 실제 주문 ---
            # 수수료를 고려하여, 실제 코인을 사는 데 사용할 순수 금액 계산
            net_investment = cash_to_invest / (1 + fee_rate)

            # 현재가 조회
            ticker = bithumb_api_client.get_ticker_info(coin_symbol)
            if ticker:
                current_price = float(ticker['closing_price'])
                if current_price <= 0: return  # 가격이 0 이하면 주문 불가

                quantity_to_buy = net_investment / current_price

                updated_portfolio = virtual_portfolio_manager.record_vportfolio_trade(
                    self.vportfolio, "buy", coin_symbol, quantity_to_buy, current_price
                )
                if updated_portfolio: self.vportfolio = updated_portfolio

    def _check_and_execute_real_sell_strategy(self, coin_symbol, owned_coin_info):
        """[실전 모드] 보유 코인에 대한 매도 전략을 확인하고 실제 주문을 실행합니다."""
        print(f"-> [{coin_symbol}] 보유 중. 실전 매도 조건을 확인합니다.")

        # --- 👇 핵심 수정 1: 'available' 수량 직접 계산! 👇 ---
        balance = float(owned_coin_info.get('balance', 0))
        locked = float(owned_coin_info.get('locked', 0))
        available_quantity = balance - locked

        if available_quantity <= 0:
            print(f"\t-> [{coin_symbol}] 주문에 묶여있거나, 사용 가능한 수량이 없습니다.")
            return
        # --- 👆 여기까지 👆 ---

        ticker = bithumb_api_client.get_ticker_info(coin_symbol)
        if not ticker:
            print(f"\t-> [{coin_symbol}] 현재가 조회 실패. 매도 판단을 건너뜁니다.")
            return

        current_price = float(ticker['closing_price'])
        avg_buy_price = float(owned_coin_info.get('avg_buy_price', 0))

        if avg_buy_price == 0:
            print(f"\t-> [{coin_symbol}] 평단가 정보가 없어 수익률을 계산할 수 없습니다.")
            return

        profit_rate = (current_price - avg_buy_price) / avg_buy_price
        print(f"\t-> [{coin_symbol}] 현재 수익률: {profit_rate:+.2%}")

        # --- 👇 핵심 수정 2: vportfolio가 아닌 self에서 전략 파라미터 가져오기 👇 ---
        pt_rate = self.vportfolio['strategy_params']['profit_take_percentage']
        sl_rate = self.vportfolio['strategy_params']['stop_loss_percentage']
        # --- 👆 (이 로직은 그대로 두는 것이 좋다고 판단!) 👆 ---

        should_sell = False
        if profit_rate >= pt_rate:
            print(f"\t-> [{coin_symbol}] 💰 수익실현 조건 달성! 실제 매도를 시도합니다.")
            should_sell = True
        elif profit_rate <= -sl_rate:
            print(f"\t-> [{coin_symbol}] 🛡️ 손절매 조건 달성! 실제 매도를 시도합니다.")
            should_sell = True

        if should_sell:
            # 수수료 및 최소 주문 금액 문제를 고려하여, 실제 주문 가능한 수량 계산
            fee_rate = 0.0025
            quantity_to_sell = available_quantity / (1 + fee_rate)

            print(f"-> 매도 수량 (수수료 고려): {quantity_to_sell:.4f} {coin_symbol}")

            if (quantity_to_sell * current_price) < 5000:
                print(f"-> 총 매도 금액이 5,000원 미만이라 주문을 실행하지 않습니다.")
                return

            # ★★★ 실제 매도 주문 실행! ★★★
            bithumb_api_client.place_order(
                order_currency=coin_symbol,
                side="sell",
                ord_type="market_sell",
                units=quantity_to_sell
            )
            trade_info = {
                "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "type": "sell", "symbol": coin_symbol.upper(),
                "quantity": quantity_to_sell, "price_per_unit": current_price
            }
            trade_history.add_trade_to_history(trade_info)

    def _check_and_execute_real_buy_strategy(self, coin_symbol):
        """[실전 모드] 미보유 코인에 대한 매수 전략을 확인하고 실제 주문을 실행합니다."""
        # 1. 기술적 분석으로 매수 신호 확인 (가상 모드와 동일)
        candlestick_data = bithumb_api_client.get_candlestick_data(coin_symbol, chart_intervals=self.analysis_interval)
        signal = "HOLD"
        if candlestick_data and len(candlestick_data) > 20:
            df = pd.DataFrame(candlestick_data, columns=['timestamp', 'open', 'close', 'high', 'low', 'volume'])
            df['close'] = pd.to_numeric(df['close'])
            df['SMA5'] = technical_analyzer.calculate_sma(candlestick_data, period=5)
            df['SMA20'] = technical_analyzer.calculate_sma(candlestick_data, period=20)
            signal = self._check_cross_signal(df)

        print(f"-> [{coin_symbol}] 미보유. 기술적 신호: {signal}")

        # 2. 매수 신호 발생 시 실제 매수 주문 실행
        if signal == "BUY":
            print(f"-> [{coin_symbol}] 🟢 골든 크로스 발생! 실제 신규 매수를 시도합니다.")

            # 실제 사용 가능한 KRW 조회
            available_cash = 0.0
            real_account_info = bithumb_api_client.get_account_info()
            if real_account_info is None:
                print("🔥 [실전 모드 에러] 계좌 정보를 가져올 수 없어 사이클을 건너뜁니다.")
                return
            for asset in real_account_info:
                if asset.get('currency') == 'KRW':
                    available_cash = float(asset.get('balance'))
                    break

            # 수수료 및 최소 주문 금액을 고려한 적응형 투자 금액 결정
            fee_rate = 0.0025
            cash_to_invest = 0

            if available_cash <= 10000:  # 1만원 이하면 전액 투자
                cash_to_invest = available_cash
            else:
                intended_investment = available_cash * self.buy_rate
                cash_to_invest = 5000 if intended_investment < 5000 else intended_investment

            if cash_to_invest < 5000:
                print(f"-> 보유 현금({available_cash:,.0f}원)이 최소 주문 금액(5000원)보다 적어 매수를 건너뜁니다.")
                return

            # 수수료를 고려한 순수 매수 금액
            net_investment = cash_to_invest / (1 + fee_rate)
            print(f"-> 투자 금액: {net_investment:,.0f} KRW")

            # ★★★ 실제 매수 주문 실행! ★★★
            bithumb_api_client.place_order(
                order_currency=coin_symbol,
                side="buy",
                ord_type="market_buy",  # 시장가 매수
                price=net_investment  # 시장가 매수는 총액 기준
            )

            quantity_to_buy = 0
            current_price = 0.0
            ticker = bithumb_api_client.get_ticker_info(coin_symbol)
            if not ticker:
                quantity_to_buy = 0
                current_price = 0.0
            else:
                current_price = float(ticker['closing_price'])
                quantity_to_buy = net_investment / current_price
            trade_info = {
                "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "type": "sell", "symbol": coin_symbol.upper(),
                "quantity": quantity_to_buy, "price_per_unit": current_price
            }
            trade_history.add_trade_to_history(trade_info)

    def _check_and_settle(self):
        """중간 정산 로직"""
        if self.trade_stats["total_trades"] >= self.settle_count:
            odds = self.trade_stats["wins"] / self.trade_stats["total_trades"]
            print("=" * 50)
            print(f"⭐[중간 정산] 최근 {self.trade_stats['total_trades']}회 거래 실적⭐")
            print(f"승리 횟수: {self.trade_stats['wins']} / 총 수익 금액: {self.trade_stats['total_gain']:,.0f} KRW")
            print(f"패배 횟수: {self.trade_stats['losses']} / 총 손실 금액: {self.trade_stats['total_loss']:,.0f} KRW")
            print(f"승률 : {odds:.2%}")

            # 승률에 따른 매수 비율 조정
            if odds >= 0.6 and self.buy_rate < 0.5:
                old_buy_rate = self.buy_rate
                self.buy_rate += 0.1
                print(f"  --> 높은 승률로 매수 비율이 상승🔼하였습니다. [ {old_buy_rate:.1%} --> {self.buy_rate:.1%}]")
            elif odds <= 0.4 and self.buy_rate > 0.1:
                old_buy_rate = self.buy_rate
                self.buy_rate -= 0.1
                print(f"  --> 낮은 승률로 매수 비율이 하락🔽하였습니다. [ {old_buy_rate:.1%} --> {self.buy_rate:.1%}]")
            print("=" * 50)

            # 정산 후 통계 초기화
            self.trade_stats = {"total_trades": 0, "wins": 0, "losses": 0, "total_gain": 0.0, "total_loss": 0.0}

    def _print_total_assets(self):
        """총 자산 현황 출력 로직"""
        print("\n" + "=" * 20 + " ⚖️ 포트폴리오 현황 ⚖️ " + "=" * 20)

        total_coin_value = 0.0
        for coin in self.vportfolio.get('coins_owned', []):
            ticker = bithumb_api_client.get_ticker_info(coin["symbol"])
            if ticker:
                current_price = float(ticker['closing_price'])
                coin_value = coin["quantity"] * current_price
                total_coin_value += coin_value
                if coin['avg_buy_price'] < 10.0 or current_price < 10.0:
                    print(f"  - 보유: {coin['symbol']:<8} 수량: {coin['quantity']:<15.4f} 평단가: {coin['avg_buy_price']:>12,.4f} KRW   |   현재가: {current_price:>12,.4f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(coin['avg_buy_price']-current_price)/coin['avg_buy_price']:>6,.1%}")
                else:
                    print(f"  - 보유: {coin['symbol']:<8} 수량: {coin['quantity']:<15.4f} 평단가: {coin['avg_buy_price']:>12,.0f} KRW   |   현재가: {current_price:>12,.0f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(coin['avg_buy_price']-current_price)/coin['avg_buy_price']:>6,.1%}")

        current_cash = self.vportfolio.get("cash", 0.0)
        total_assets = current_cash + total_coin_value
        pnl_percentage = ((total_assets - self.initial_capital) / self.initial_capital) * 100 if self.initial_capital > 0 else 0

        print(f"  - 현금: {current_cash:,.0f} KRW")
        print("-" * 62)
        print(f"  - 총 자산: {total_assets:,.0f} KRW (초기 자산 대비: {pnl_percentage:+.2f}%)")
        print("=" * 62)

    def _print_real_total_assets(self):
        real_account_info = bithumb_api_client.get_account_info()
        """[실전 모드] 실제 총 자산 현황을 출력합니다."""
        print("\n" + "=" * 20 + " ⚖️ 실제 계좌 현황 ⚖️ " + "=" * 20)

        if not isinstance(real_account_info, list):
            print("자산 정보를 표시할 수 없습니다.")
            return

        total_coin_value = 0.0
        current_cash = 0.0

        for asset in real_account_info:
            currency = asset.get('currency')
            balance = float(asset.get('balance', 0))

            if currency == 'KRW':
                current_cash = balance
                continue  # KRW는 코인이 아니므로 아래 로직 건너뛰기

            balance = float(asset.get('balance', 0))
            locked = float(asset.get('locked', 0))
            avg_buy_price = float(asset.get('avg_buy_price', 0))
            available_quantity = balance - locked
            if  currency == "P" or available_quantity * avg_buy_price < 5000:
                continue

            if balance > 0:
                ticker = bithumb_api_client.get_ticker_info(currency)
                if ticker:
                    current_price = float(ticker.get('closing_price', 0))
                    coin_value = balance * current_price
                    total_coin_value += coin_value
                    #print(f"  - 보유: {currency:<8} 수량: {balance:<15.4f}   |   현재가: {current_price:>12,.0f} KRW   |   평가액: {coin_value:>12,.0f} KRW")
                    if avg_buy_price < 10.0 or current_price < 10.0:
                        print(f"  - 보유: {currency:<8} 수량: {balance:<15.4f} 평단가: {avg_buy_price:>12,.4f} KRW   |   현재가: {current_price:>12,.4f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(current_price - avg_buy_price) / avg_buy_price:>6,.2%}")
                    else:
                        print(f"  - 보유: {currency:<8} 수량: {balance:<15.4f} 평단가: {avg_buy_price:>12,.0f} KRW   |   현재가: {current_price:>12,.0f} KRW   |   평가액: {coin_value:>12,.0f} KRW   |   수익률: {(current_price - avg_buy_price) / avg_buy_price:>6,.2%}")

        total_assets = current_cash + total_coin_value
        # 실전 모드의 수익률은 시작 시점의 자산을 기준으로 계산해야 하므로 initial_capital 사용
        pnl_percentage = ((total_assets - self.initial_capital) / self.initial_capital) * 100 if self.initial_capital > 0 else 0

        print(f"  - 보유 현금: {current_cash:,.0f} KRW")
        print("-" * 62)
        print(f"  - 총 자산: {total_assets:,.0f} KRW (초기 자산 대비: {pnl_percentage:+.2f}%)")
        print("=" * 62)

    def run_one_cycle(self):
        """한 번의 분석/거래 사이클 전체를 지휘하는 메인 메소드"""
        # --- 쿨다운(거래 중지) 상태인지 먼저 확인! ---
        is_in_cooldown = False
        cooldown_until = self.vportfolio.get("cooldown_until", 0)
        if time.time() < cooldown_until:
            is_in_cooldown = True
            resume_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(cooldown_until))
            print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] ❄️ 거래 중지(쿨다운) 상태입니다. ({resume_time_str} 까지)")
        elif cooldown_until > 0:
            # 쿨다운 시간이 지났다면, 상태를 초기화
            print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] ✅ 쿨다운 기간이 종료되었습니다. 거래를 재개합니다.")
            self.vportfolio["consecutive_losses"] = 0
            self.vportfolio["cooldown_until"] = 0
            virtual_portfolio_manager.save_vportfolio(self.vportfolio)  # 상태 초기화 후 저장

        # 1. 분석 대상 선정
        real_account_info = None
        owned_symbols = []

        if self.is_virtual_mode is True:
            # 가상 모드: vportfolio 파일에서 보유 코인 목록 가져오기
            owned_symbols = [coin['symbol'] for coin in self.vportfolio.get('coins_owned', [])]
        else:  # 실전 모드
            real_account_info = bithumb_api_client.get_account_info()
            if real_account_info is None:
                print("🔥 [실전 모드 에러] 계좌 정보를 가져올 수 없어 사이클을 건너뜁니다.")
                return
            print("✅ [실전 모드] 실제 계좌 정보를 성공적으로 조회했습니다.")
            # 실제 보유 코인 목록 생성
            for asset in real_account_info:
                if asset.get('currency') == 'KRW' or asset.get('currency') == 'P':
                    continue
                balance = float(asset.get('balance', 0))
                locked = float(asset.get('locked', 0))
                available_quantity = balance - locked
                if available_quantity * float(asset.get('avg_buy_price')) < 5000:
                    continue
                owned_symbols.append(asset.get('currency'))

        top_coins = self._filter_top_coins()
        combined_list = list(set(top_coins + owned_symbols))
        final_analysis_list = [coin for coin in combined_list if coin not in self.hodl_list]

        print(f"\n--- 최종 분석 대상 ({len(final_analysis_list)}개) ---")
        print(final_analysis_list)
        print("-" * 50)

        # 2. 각 코인 분석 및 거래 실행
        for coin_symbol in final_analysis_list:
            owned_coin_info = None
            if self.is_virtual_mode:
                owned_coin_info = next((c for c in self.vportfolio["coins_owned"] if c["symbol"] == coin_symbol), None)
            else:  # 실전 모드
                owned_coin_info = next((asset for asset in real_account_info if asset.get('currency') == coin_symbol), None)

            # 보유/미보유에 따른 전략 실행
            if owned_coin_info:
                balance = float(owned_coin_info.get('balance', 0))
                locked = float(owned_coin_info.get('locked', 0))
                available_quantity = balance - locked
                if available_quantity * float(owned_coin_info.get('avg_buy_price')) < 5000:
                    continue

                if self.is_virtual_mode:
                    self._check_and_execute_sell_strategy(coin_symbol, owned_coin_info)
                else:  # 실전 모드
                    self._check_and_execute_real_sell_strategy(coin_symbol, owned_coin_info)
            elif not is_in_cooldown:
                if self.is_virtual_mode:
                    self._check_and_execute_buy_strategy(coin_symbol)
                else:  # 실전 모드
                    self._check_and_execute_real_buy_strategy(coin_symbol)

        # 3. 사이클 종료 후 중간 정산 및 자산 현황 출력
        self._check_and_settle()
        if self.is_virtual_mode:
            self._print_total_assets()
        else:
            self._print_real_total_assets()

 

 

끝!!!!!!