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
|
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:
|
def sell(ticker: str, reason: str = "") -> bool:
|
||||||
"""시장가 전량 매도."""
|
"""시장가 전량 매도."""
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -315,28 +368,36 @@ def sell(ticker: str, reason: str = "") -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current = pyupbit.get_current_price(ticker)
|
time.sleep(0.5) # 체결 완료 대기
|
||||||
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
|
order_uuid = result.get("uuid", "") if isinstance(result, dict) else ""
|
||||||
fee = pos["invested_krw"] * 0.0005 + sell_value * 0.0005
|
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
|
krw_profit = sell_value - pos["invested_krw"] - fee
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[매도] {ticker} @ {current:,.0f}원 | "
|
f"[매도] {ticker} @ {actual_sell_price:,.4f}원 | "
|
||||||
f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (수수료 {fee:,.0f}원) | 사유={reason}"
|
f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (수수료 {fee:,.0f}원) | 사유={reason}"
|
||||||
)
|
)
|
||||||
notify_sell(ticker, current, pnl, reason)
|
notify_sell(ticker, actual_sell_price, pnl, reason)
|
||||||
if current:
|
_last_sell_prices[ticker] = actual_sell_price
|
||||||
_last_sell_prices[ticker] = current # 재매수 기준가 기록
|
try:
|
||||||
try:
|
upsert_sell_price(ticker, actual_sell_price)
|
||||||
upsert_sell_price(ticker, current) # DB 영구 저장
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}")
|
||||||
logger.error(f"직전 매도가 DB 저장 실패 {ticker}: {e}")
|
|
||||||
_update_history(
|
_update_history(
|
||||||
ticker, pnl > 0, pnl, fee, krw_profit,
|
ticker, pnl > 0, pnl, fee, krw_profit,
|
||||||
trade_id=pos.get("trade_id", ""),
|
trade_id=pos.get("trade_id", ""),
|
||||||
buy_price=pos["buy_price"],
|
buy_price=pos["buy_price"],
|
||||||
sell_price=current or pos["buy_price"],
|
sell_price=actual_sell_price,
|
||||||
invested_krw=pos["invested_krw"],
|
invested_krw=pos["invested_krw"],
|
||||||
sell_reason=reason,
|
sell_reason=reason,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user