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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user