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 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-01 06:11:29 +09:00
parent bcef128155
commit 035b3e2f30

View File

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