From 035b3e2f3009b0c536c99bc4b71d4306592d6d82 Mon Sep 17 00:00:00 2001 From: joungmin Date: Sun, 1 Mar 2026 06:11:29 +0900 Subject: [PATCH] fix: use actual order fills for weighted avg sell price After sell_market_order, query Upbit /v1/order API to get actual trade fills. If split across multiple fills, compute weighted average price and use actual paid_fee instead of estimate. Falls back to get_current_price if order query fails. Co-Authored-By: Claude Sonnet 4.6 --- core/trader.py | 89 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/core/trader.py b/core/trader.py index fabb9f4..4f7aabf 100644 --- a/core/trader.py +++ b/core/trader.py @@ -286,6 +286,59 @@ def buy(ticker: str) -> bool: return False +def _get_avg_fill_price( + upbit: pyupbit.Upbit, + order_uuid: str, + ticker: str, + fallback: float, +) -> tuple[float, float | None]: + """주문 UUID로 실제 체결 내역을 조회해 가중평균 체결가와 실수수료를 반환. + + 분할 체결(여러 fills)이면 합산 평균가 계산. + 조회 실패 시 (fallback_price, None) 반환. + """ + if not order_uuid: + return fallback, None + try: + import hashlib + import jwt as _jwt + import requests as _req + + query_str = f"uuid={order_uuid}" + payload = { + "access_key": upbit.access_key, + "nonce": str(uuid.uuid4()), + "query_hash": hashlib.sha512(query_str.encode()).hexdigest(), + "query_hash_alg": "SHA512", + } + token = _jwt.encode(payload, upbit.secret_key, algorithm="HS256") + resp = _req.get( + "https://api.upbit.com/v1/order", + params={"uuid": order_uuid}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + data = resp.json() + trades = data.get("trades", []) + if not trades: + return fallback, None + + total_vol = sum(float(t["volume"]) for t in trades) + total_krw = sum(float(t["price"]) * float(t["volume"]) for t in trades) + avg_price = total_krw / total_vol if total_vol > 0 else fallback + paid_fee = float(data.get("paid_fee", 0)) + + if len(trades) > 1: + logger.info( + f"[분할체결] {ticker} {len(trades)}건 → " + f"평균={avg_price:,.4f}원 (수수료={paid_fee:,.0f}원)" + ) + return avg_price, paid_fee + except Exception as e: + logger.debug(f"[체결조회 실패] {ticker} uuid={order_uuid[:8]}: {e}") + return fallback, None + + def sell(ticker: str, reason: str = "") -> bool: """시장가 전량 매도.""" with _lock: @@ -315,28 +368,36 @@ def sell(ticker: str, reason: str = "") -> bool: return True return False - current = pyupbit.get_current_price(ticker) - pnl = (current - pos["buy_price"]) / pos["buy_price"] * 100 if current else 0.0 - # 실제 KRW 손익 및 수수료 계산 (업비트 수수료 0.05% 양방향) - sell_value = (current or pos["buy_price"]) * actual_amount - fee = pos["invested_krw"] * 0.0005 + sell_value * 0.0005 + time.sleep(0.5) # 체결 완료 대기 + + # 실제 체결 내역으로 가중평균 매도가 계산 (분할 체결 대응) + order_uuid = result.get("uuid", "") if isinstance(result, dict) else "" + fallback_price = pyupbit.get_current_price(ticker) or pos["buy_price"] + actual_sell_price, actual_fee_from_order = _get_avg_fill_price( + upbit, order_uuid, ticker, fallback_price + ) + + pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100 + sell_value = actual_sell_price * actual_amount + # 수수료: 주문 조회 성공 시 실제값, 아니면 추정값 (0.05% 양방향) + fee = actual_fee_from_order if actual_fee_from_order is not None \ + else (pos["invested_krw"] * 0.0005 + sell_value * 0.0005) krw_profit = sell_value - pos["invested_krw"] - fee logger.info( - f"[매도] {ticker} @ {current:,.0f}원 | " + f"[매도] {ticker} @ {actual_sell_price:,.4f}원 | " f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (수수료 {fee:,.0f}원) | 사유={reason}" ) - notify_sell(ticker, current, pnl, reason) - if current: - _last_sell_prices[ticker] = current # 재매수 기준가 기록 - try: - upsert_sell_price(ticker, current) # DB 영구 저장 - except Exception as e: - logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}") + notify_sell(ticker, actual_sell_price, pnl, reason) + _last_sell_prices[ticker] = actual_sell_price + try: + upsert_sell_price(ticker, actual_sell_price) + except Exception as e: + logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}") _update_history( ticker, pnl > 0, pnl, fee, krw_profit, trade_id=pos.get("trade_id", ""), buy_price=pos["buy_price"], - sell_price=current or pos["buy_price"], + sell_price=actual_sell_price, invested_krw=pos["invested_krw"], sell_reason=reason, )