feat: 实际成交价追踪 + reduceOnly保护 + 数据库增强
- Bitget GetTradeFee 返回实际成交均价(weighted avg fill price)及手续费, 支持最多5次重试 - HyperLiquid ParseFillFromResponse 提取订单成交均价, 新增 GetSize/GetBalance 方法 - 开仓/加仓/平仓均从交易所获取实际成交价替代WS估算价 - HL 平仓使用 reduceOnly 防止反向开仓 - 所有 OrderID 保存到数据库 orders 表 - 加仓零成交检测及实际手续费获取 - PnL 计算修正为按USD计算手续费 - 新增 GetAllClosedTrades / GetClosedStats 数据库查询 - 服务器重启 restore 未完成交易修复(DBTradeID 缺失) - 黑名单强平添加 USD PnL/手续费预计算 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c0cafb0400
commit
f97ac16b1c
@@ -111,6 +111,13 @@ func (d *DB) SetTradeStatus(id int64, status string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTradeEntry updates entry-related fields on an existing trade (prices, exchanges, spread).
|
||||||
|
func (d *DB) UpdateTradeEntry(id int64, t *TradeRecord) error {
|
||||||
|
_, err := d.Exec(`UPDATE trades SET long_entry=?, short_entry=?, long_exchange=?, short_exchange=?, entry_spread=? WHERE id=?`,
|
||||||
|
t.LongEntry, t.ShortEntry, t.LongExchange, t.ShortExchange, t.EntrySpread, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetOpenTrades returns all non-closed trades (status='open' or status='entering').
|
// GetOpenTrades returns all non-closed trades (status='open' or status='entering').
|
||||||
func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
||||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
|
|||||||
+81
-32
@@ -48,7 +48,7 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide s
|
|||||||
"tradeSide": tradeSide,
|
"tradeSide": tradeSide,
|
||||||
"size": size,
|
"size": size,
|
||||||
}
|
}
|
||||||
// When closing, Bitget requires holdSide to identify which position to close
|
// Close orders require holdSide to identify which position to close
|
||||||
if tradeSide == "close" && holdSide != "" {
|
if tradeSide == "close" && holdSide != "" {
|
||||||
body["holdSide"] = holdSide
|
body["holdSide"] = holdSide
|
||||||
}
|
}
|
||||||
@@ -85,21 +85,84 @@ func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size, tradeSide, holdSide s
|
|||||||
}
|
}
|
||||||
return result.Data.OrderID, nil
|
return result.Data.OrderID, nil
|
||||||
}
|
}
|
||||||
// GetTradeFee queries the fills endpoint for actual fee charged.
|
// GetTradeFee queries the fills endpoint for actual fee charged and average fill price.
|
||||||
// Waits 1s before querying because Bitget's fills API may lag behind
|
// Retries up to 5 times with 500ms intervals because Bitget's fills API may lag.
|
||||||
// the place-order response. Returns 0 if no fills yet (caller uses
|
// Returns (average fill price, fee in USD, error). avgPrice=0 on any fills issue.
|
||||||
// estimated fee from config as fallback).
|
func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (avgPrice, feeUSD float64, err error) {
|
||||||
func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err error) {
|
for i := 0; i < 5; i++ {
|
||||||
time.Sleep(1 * time.Second)
|
if i > 0 {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
|
method := "GET"
|
||||||
|
requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES"
|
||||||
|
host := "https://api.bitget.com"
|
||||||
|
|
||||||
|
sign := b.sign(method, requestPath, ts, "")
|
||||||
|
url := host + requestPath
|
||||||
|
req, _ := http.NewRequest(method, url, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||||
|
req.Header.Set("ACCESS-SIGN", sign)
|
||||||
|
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||||
|
req.Header.Set("ACCESS-PASSPHRASE", b.Passphrase)
|
||||||
|
|
||||||
|
resp, err := b.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var raw struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
FillList []json.RawMessage `json:"fillList"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &raw); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parse: %s", string(respBody))
|
||||||
|
}
|
||||||
|
if raw.Code != "00000" {
|
||||||
|
return 0, 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalFee, totalQty, totalCost float64
|
||||||
|
for _, item := range raw.Data.FillList {
|
||||||
|
var fill struct {
|
||||||
|
FillPrice string `json:"fillPrice"`
|
||||||
|
FillSize string `json:"fillBaseSize"`
|
||||||
|
FillFee string `json:"fillFee"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(item, &fill); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, _ := strconv.ParseFloat(fill.FillFee, 64)
|
||||||
|
p, _ := strconv.ParseFloat(fill.FillPrice, 64)
|
||||||
|
q, _ := strconv.ParseFloat(fill.FillSize, 64)
|
||||||
|
totalFee += math.Abs(f)
|
||||||
|
totalCost += p * q
|
||||||
|
totalQty += q
|
||||||
|
}
|
||||||
|
if totalQty > 0 {
|
||||||
|
return totalCost / totalQty, totalFee, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("no fill data after 5 attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPosition returns the available position size for a coin, or 0 if no position.
|
||||||
|
// Returns (total as float64, raw total string from API) — the raw string can be used
|
||||||
|
// for close orders to ensure correct precision.
|
||||||
|
func (b *BitgetTrade) CheckPosition(symbol string) (float64, string) {
|
||||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
method := "GET"
|
method := "GET"
|
||||||
requestPath := "/api/v2/mix/order/fills?symbol=" + symbol + "&orderId=" + orderID + "&productType=USDT-FUTURES"
|
requestPath := "/api/v2/mix/position/single-position?symbol=" + symbol + "&productType=USDT-FUTURES&marginCoin=USDT"
|
||||||
host := "https://api.bitget.com"
|
host := "https://api.bitget.com"
|
||||||
|
|
||||||
sign := b.sign(method, requestPath, ts, "")
|
sign := b.sign(method, requestPath, ts, "")
|
||||||
url := host + requestPath
|
url := host + requestPath
|
||||||
req, _ := http.NewRequest(method, url, nil)
|
req, _ := http.NewRequest(method, url, nil)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("ACCESS-KEY", b.APIKey)
|
req.Header.Set("ACCESS-KEY", b.APIKey)
|
||||||
req.Header.Set("ACCESS-SIGN", sign)
|
req.Header.Set("ACCESS-SIGN", sign)
|
||||||
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
req.Header.Set("ACCESS-TIMESTAMP", ts)
|
||||||
@@ -107,37 +170,23 @@ func (b *BitgetTrade) GetTradeFee(symbol, orderID string) (feeUSD float64, err e
|
|||||||
|
|
||||||
resp, err := b.client.Do(req)
|
resp, err := b.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("http: %w", err)
|
return 0, ""
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
var raw struct {
|
var raw struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Msg string `json:"msg"`
|
Data []struct {
|
||||||
Data struct {
|
Total string `json:"total"`
|
||||||
FillList []json.RawMessage `json:"fillList"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respBody, &raw); err != nil {
|
json.Unmarshal(respBody, &raw)
|
||||||
return 0, fmt.Errorf("parse: %s", string(respBody))
|
if raw.Code != "00000" || len(raw.Data) == 0 {
|
||||||
|
return 0, ""
|
||||||
}
|
}
|
||||||
if raw.Code != "00000" {
|
total, _ := strconv.ParseFloat(raw.Data[0].Total, 64)
|
||||||
return 0, fmt.Errorf("bitget error: %s - %s", raw.Code, raw.Msg)
|
return total, raw.Data[0].Total
|
||||||
}
|
|
||||||
|
|
||||||
var totalFee float64
|
|
||||||
for _, item := range raw.Data.FillList {
|
|
||||||
var fill struct {
|
|
||||||
FillFee string `json:"fillFee"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(item, &fill); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f, _ := strconv.ParseFloat(fill.FillFee, 64)
|
|
||||||
totalFee += math.Abs(f)
|
|
||||||
}
|
|
||||||
return totalFee, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
|
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
|
||||||
|
|||||||
@@ -168,32 +168,77 @@ func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, erro
|
|||||||
return string(respJSON), nil
|
return string(respJSON), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlaceMarketCloseOrder closes a position on HL with reduceOnly protection.
|
||||||
|
// Uses the SDK's MarketClose which sets ReduceOnly=true to prevent accidental reversals.
|
||||||
|
// sz is the size string (same format as PlaceMarketOrder). Pass "0" or "" to close full position.
|
||||||
|
func (h *HyperLiquidTrade) PlaceMarketCloseOrder(coin, sz string) (string, error) {
|
||||||
|
if !h.configured {
|
||||||
|
return "", fmt.Errorf("HL not configured")
|
||||||
|
}
|
||||||
|
if err := h.initExchange(); err != nil {
|
||||||
|
return "", fmt.Errorf("init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var size *float64
|
||||||
|
if f, err := strconv.ParseFloat(sz, 64); err == nil && f > 0 {
|
||||||
|
size = &f
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Printf("[Order] HL MarketClose | coin=%s size=%v reduceOnly=true slippage=0.05", coin, size)
|
||||||
|
result, err := h.exchange.MarketClose(ctx, coin, size, nil, 0.05, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("market close: %w", err)
|
||||||
|
}
|
||||||
|
respJSON, _ := json.Marshal(result)
|
||||||
|
return string(respJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EstimateFeeFromResponse calculates the fee using the response's filled size × price
|
// EstimateFeeFromResponse calculates the fee using the response's filled size × price
|
||||||
// × configured taker rate. This is NOT an actual fee from HL — HL does not return
|
// × configured taker rate. This is NOT an actual fee from HL — HL does not return
|
||||||
// fee amounts in the order response. The result is equivalent to estimating from
|
// fee amounts in the order response. The result is equivalent to estimating from
|
||||||
// TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px.
|
// TradeAmountUSD, but more accurate for partial fills since it uses actual filled sz/px.
|
||||||
func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) {
|
func (h *HyperLiquidTrade) EstimateFeeFromResponse(orderResponseJSON string, takerFeePct float64) (feeUSD float64, err error) {
|
||||||
// HL MarketOpen returns a single OrderStatus object (NOT wrapped in statuses array):
|
|
||||||
// {"resting":..., "filled":{"totalSz":"82.5","avgPx":"0.12153","oid":52463955193}, "error":...}
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Resting *json.RawMessage `json:"resting,omitempty"`
|
Filled *struct {
|
||||||
Filled *struct {
|
TotalSz string `json:"totalSz"`
|
||||||
|
AvgPx string `json:"avgPx"`
|
||||||
|
} `json:"filled,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil || resp.Filled == nil {
|
||||||
|
return 0, fmt.Errorf("no filled data in response")
|
||||||
|
}
|
||||||
|
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||||||
|
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||||||
|
if sz > 0 && px > 0 {
|
||||||
|
return sz * px * takerFeePct / 100, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no filled status in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFillFromResponse extracts the average fill price and total filled size
|
||||||
|
// from an HL MarketOpen/MarketClose response. Returns (avgFillPrice, filledSize, error).
|
||||||
|
func (h *HyperLiquidTrade) ParseFillFromResponse(orderResponseJSON string) (avgPrice, filledSize float64, err error) {
|
||||||
|
var resp struct {
|
||||||
|
Filled *struct {
|
||||||
TotalSz string `json:"totalSz"`
|
TotalSz string `json:"totalSz"`
|
||||||
AvgPx string `json:"avgPx"`
|
AvgPx string `json:"avgPx"`
|
||||||
} `json:"filled,omitempty"`
|
} `json:"filled,omitempty"`
|
||||||
Error *string `json:"error,omitempty"`
|
Error *string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil {
|
if err := json.Unmarshal([]byte(orderResponseJSON), &resp); err != nil {
|
||||||
return 0, fmt.Errorf("parse: %w", err)
|
return 0, 0, fmt.Errorf("parse: %w", err)
|
||||||
}
|
}
|
||||||
if resp.Filled != nil {
|
if resp.Filled != nil {
|
||||||
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
sz, _ := strconv.ParseFloat(resp.Filled.TotalSz, 64)
|
||||||
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
px, _ := strconv.ParseFloat(resp.Filled.AvgPx, 64)
|
||||||
if sz > 0 && px > 0 {
|
if sz > 0 && px > 0 {
|
||||||
return sz * px * takerFeePct / 100, nil
|
return px, sz, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, fmt.Errorf("no filled status in response")
|
return 0, 0, fmt.Errorf("no filled data in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyperLiquidTrade) GetBalance() (float64, error) {
|
func (h *HyperLiquidTrade) GetBalance() (float64, error) {
|
||||||
|
|||||||
@@ -366,11 +366,17 @@ func (t *Trader) ClosePosition(coin string) error {
|
|||||||
return fmt.Errorf("close failed: %s", closeErr)
|
return fmt.Errorf("close failed: %s", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use entry prices as exit price estimate (manual close — no live price snapshot)
|
// Use actual exit fill prices from exchange (captured by closeLeg), fall back to entry prices
|
||||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD)
|
longExitPx := pos.LongLeg.ExitPrice
|
||||||
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
if longExitPx <= 0 {
|
||||||
|
longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD)
|
||||||
|
}
|
||||||
|
shortExitPx := pos.ShortLeg.ExitPrice
|
||||||
|
if shortExitPx <= 0 {
|
||||||
|
shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
||||||
|
}
|
||||||
|
|
||||||
t.finalizeClosedPosition(pos, longAvg, shortAvg, 0, 0, 0, 0, 0, longAvg, shortAvg, "手动", "手动平仓", elapsed)
|
t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "手动平仓", elapsed)
|
||||||
log.Printf("[Trader] Manually closed %s %s — persisted to DB", coin, pos.Direction)
|
log.Printf("[Trader] Manually closed %s %s — persisted to DB", coin, pos.Direction)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -395,9 +401,15 @@ func (t *Trader) CloseAllPositions() int {
|
|||||||
log.Printf("[Trader] ❌ Force-close %s failed: %s", pos.Coin, closeErr)
|
log.Printf("[Trader] ❌ Force-close %s failed: %s", pos.Coin, closeErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD)
|
longExitPx := pos.LongLeg.ExitPrice
|
||||||
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
if longExitPx <= 0 {
|
||||||
t.finalizeClosedPosition(pos, longAvg, shortAvg, 0, 0, 0, 0, 0, longAvg, shortAvg, "手动", "全部平仓", elapsed)
|
longExitPx = weightedAvgPrice(pos.LongEntryPrices, t.cfg.TradeAmountUSD)
|
||||||
|
}
|
||||||
|
shortExitPx := pos.ShortLeg.ExitPrice
|
||||||
|
if shortExitPx <= 0 {
|
||||||
|
shortExitPx = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
||||||
|
}
|
||||||
|
t.finalizeClosedPosition(pos, longExitPx, shortExitPx, 0, 0, 0, 0, 0, longExitPx, shortExitPx, "手动", "全部平仓", elapsed)
|
||||||
log.Printf("[Trader] Force-closed %s %s — persisted to DB", pos.Coin, pos.Direction)
|
log.Printf("[Trader] Force-closed %s %s — persisted to DB", pos.Coin, pos.Direction)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
@@ -696,6 +708,47 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
|||||||
pos.DBTradeID = pendingTradeID
|
pos.DBTradeID = pendingTradeID
|
||||||
log.Printf("[Trader] %s: DB status entering->open (id=%d)", pos.Coin, pendingTradeID)
|
log.Printf("[Trader] %s: DB status entering->open (id=%d)", pos.Coin, pendingTradeID)
|
||||||
}
|
}
|
||||||
|
// Save entry prices, orders, and fees (not saved in TryEntry's pending record)
|
||||||
|
now := time.Now()
|
||||||
|
status := "filled"
|
||||||
|
tradeUnit := t.cfg.TradeAmountUSD
|
||||||
|
es := pos.EntrySpread
|
||||||
|
t.db.UpdateTradeEntry(pendingTradeID, &db.TradeRecord{
|
||||||
|
LongEntry: &pos.LongLeg.EntryPrice,
|
||||||
|
ShortEntry: &pos.ShortLeg.EntryPrice,
|
||||||
|
LongExchange: pos.LongLeg.Exchange,
|
||||||
|
ShortExchange: pos.ShortLeg.Exchange,
|
||||||
|
EntrySpread: &es,
|
||||||
|
})
|
||||||
|
if longFeeUSD <= 0 {
|
||||||
|
longFeeUSD = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
}
|
||||||
|
if shortFeeUSD <= 0 {
|
||||||
|
shortFeeUSD = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
}
|
||||||
|
longShares := tradeUnit / pos.LongLeg.EntryPrice
|
||||||
|
shortShares := tradeUnit / pos.ShortLeg.EntryPrice
|
||||||
|
longOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
|
TradeID: pendingTradeID, Leg: "long", Type: "entry",
|
||||||
|
Exchange: pos.LongLeg.Exchange, Side: "buy",
|
||||||
|
Price: &pos.LongLeg.EntryPrice, Size: &longShares,
|
||||||
|
Fee: &longFeeUSD, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
|
})
|
||||||
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
|
TradeID: pendingTradeID, Leg: "short", Type: "entry",
|
||||||
|
Exchange: pos.ShortLeg.Exchange, Side: "sell",
|
||||||
|
Price: &pos.ShortLeg.EntryPrice, Size: &shortShares,
|
||||||
|
Fee: &shortFeeUSD, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
|
})
|
||||||
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
|
TradeID: pendingTradeID, Type: "entry", Status: "filled",
|
||||||
|
Spread: &es,
|
||||||
|
LongPrice: &pos.LongLeg.EntryPrice, ShortPrice: &pos.ShortLeg.EntryPrice,
|
||||||
|
LongOrderID: &longOID, ShortOrderID: &shortOID,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Fallback: no pending record, insert fresh
|
// Fallback: no pending record, insert fresh
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -733,12 +786,14 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
|||||||
Exchange: pos.LongLeg.Exchange, Side: "buy",
|
Exchange: pos.LongLeg.Exchange, Side: "buy",
|
||||||
Price: &pos.LongLeg.EntryPrice, Size: &longShares,
|
Price: &pos.LongLeg.EntryPrice, Size: &longShares,
|
||||||
Fee: &longFeeUSD, Status: &status, CreatedAt: now,
|
Fee: &longFeeUSD, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
})
|
})
|
||||||
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
TradeID: tradeID, Leg: "short", Type: "entry",
|
TradeID: tradeID, Leg: "short", Type: "entry",
|
||||||
Exchange: pos.ShortLeg.Exchange, Side: "sell",
|
Exchange: pos.ShortLeg.Exchange, Side: "sell",
|
||||||
Price: &pos.ShortLeg.EntryPrice, Size: &shortShares,
|
Price: &pos.ShortLeg.EntryPrice, Size: &shortShares,
|
||||||
Fee: &shortFeeUSD, Status: &status, CreatedAt: now,
|
Fee: &shortFeeUSD, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
})
|
})
|
||||||
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
TradeID: tradeID, Type: "entry", Status: "filled",
|
TradeID: tradeID, Type: "entry", Status: "filled",
|
||||||
@@ -820,13 +875,15 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
|||||||
longPrice, shortPrice = hlP, bgP
|
longPrice, shortPrice = hlP, bgP
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice); err != "" {
|
longErr, longFeeActual := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice)
|
||||||
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
|
if longErr != "" {
|
||||||
|
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, longErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(t.cfg.LegDelay)
|
time.Sleep(t.cfg.LegDelay)
|
||||||
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" {
|
shortErr, shortFeeActual := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice)
|
||||||
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err)
|
if shortErr != "" {
|
||||||
|
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, shortErr)
|
||||||
// Don't close the long leg — the scale-in long order was placed but the
|
// Don't close the long leg — the scale-in long order was placed but the
|
||||||
// short wasn't. The position has extra long exposure until the next Tick
|
// short wasn't. The position has extra long exposure until the next Tick
|
||||||
// decides what to do. This is a partial fill scenario.
|
// decides what to do. This is a partial fill scenario.
|
||||||
@@ -850,8 +907,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
|||||||
tradeUnit := t.cfg.TradeAmountUSD
|
tradeUnit := t.cfg.TradeAmountUSD
|
||||||
es := pos.EntrySpread
|
es := pos.EntrySpread
|
||||||
|
|
||||||
longFee := tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
|
longFee := longFeeActual
|
||||||
shortFee := tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
|
if longFee <= 0 {
|
||||||
|
longFee = tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
}
|
||||||
|
shortFee := shortFeeActual
|
||||||
|
if shortFee <= 0 {
|
||||||
|
shortFee = tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
}
|
||||||
longShares := tradeUnit / longPrice
|
longShares := tradeUnit / longPrice
|
||||||
shortShares := tradeUnit / shortPrice
|
shortShares := tradeUnit / shortPrice
|
||||||
|
|
||||||
@@ -860,12 +923,14 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store
|
|||||||
Exchange: pos.LongLeg.Exchange, Side: "buy",
|
Exchange: pos.LongLeg.Exchange, Side: "buy",
|
||||||
Price: &longPrice, Size: &longShares,
|
Price: &longPrice, Size: &longShares,
|
||||||
Fee: &longFee, Status: &status, CreatedAt: now,
|
Fee: &longFee, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
})
|
})
|
||||||
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
TradeID: pos.DBTradeID, Leg: "short", Type: "scale",
|
TradeID: pos.DBTradeID, Leg: "short", Type: "scale",
|
||||||
Exchange: pos.ShortLeg.Exchange, Side: "sell",
|
Exchange: pos.ShortLeg.Exchange, Side: "sell",
|
||||||
Price: &shortPrice, Size: &shortShares,
|
Price: &shortPrice, Size: &shortShares,
|
||||||
Fee: &shortFee, Status: &status, CreatedAt: now,
|
Fee: &shortFee, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
})
|
})
|
||||||
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
TradeID: pos.DBTradeID, Type: "scale", Status: "filled",
|
TradeID: pos.DBTradeID, Type: "scale", Status: "filled",
|
||||||
@@ -1084,6 +1149,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
||||||
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
||||||
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
|
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
})
|
})
|
||||||
shortExitShares := totalshortShares
|
shortExitShares := totalshortShares
|
||||||
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
@@ -1091,6 +1157,7 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
||||||
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
||||||
Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now,
|
Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
})
|
})
|
||||||
// Save exit system order
|
// Save exit system order
|
||||||
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
@@ -1177,13 +1244,23 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s
|
|||||||
leg.OrderID = oid
|
leg.OrderID = oid
|
||||||
log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", szStr, oid)
|
log.Printf("[ExRes] BG %s %s: size=%s oid=%s", side, leg.Coin+"USDT", szStr, oid)
|
||||||
|
|
||||||
// Fetch actual fee from exchange
|
// Fetch actual fee and fill price from exchange
|
||||||
fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid)
|
fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid)
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr)
|
log.Printf("[Fee] BG GetTradeFee warning: %v", fetchErr)
|
||||||
fee = 0
|
fee = 0
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[Fee] BG %s %s: actual fee=$%.6f (filled)", side, leg.Coin+"USDT", fee)
|
log.Printf("[Fee] BG %s %s: actual fee=$%.6f fillPrice=%.6f (filled)", side, leg.Coin+"USDT", fee, fillPrice)
|
||||||
|
if fillPrice > 0 {
|
||||||
|
leg.EntryPrice = fillPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify position actually exists on BG (IOC can succeed without fills)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 {
|
||||||
|
log.Printf("[Trader] BG %s %s: IOC order accepted but no position created (zero fill)", side, leg.Coin+"USDT")
|
||||||
|
return fmt.Sprintf("BG %s zero fill (no position)", side), 0
|
||||||
}
|
}
|
||||||
return "", fee
|
return "", fee
|
||||||
} else {
|
} else {
|
||||||
@@ -1197,7 +1274,12 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s
|
|||||||
leg.OrderID = resp
|
leg.OrderID = resp
|
||||||
log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr)
|
log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr)
|
||||||
|
|
||||||
// Estimate fee from filled response (HL doesn't return fee in order response)
|
// Parse actual fill price and estimate fee from HL response
|
||||||
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
||||||
|
if parseErr == nil && fillPrice > 0 {
|
||||||
|
leg.EntryPrice = fillPrice
|
||||||
|
log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
|
||||||
|
}
|
||||||
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid])
|
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid])
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
log.Printf("[Fee] HL EstimateFeeFromResponse warning: %v", fetchErr)
|
log.Printf("[Fee] HL EstimateFeeFromResponse warning: %v", fetchErr)
|
||||||
@@ -1306,12 +1388,14 @@ func (t *Trader) finalizeClosedPosition(pos *ArbPosition, longPrice, shortPrice,
|
|||||||
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
||||||
Price: &longPrice, Size: &totalLongShares,
|
Price: &longPrice, Size: &totalLongShares,
|
||||||
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
|
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
})
|
})
|
||||||
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
TradeID: pos.DBTradeID, Leg: "short", Type: "exit",
|
TradeID: pos.DBTradeID, Leg: "short", Type: "exit",
|
||||||
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
||||||
Price: &shortPrice, Size: &totalShortShares,
|
Price: &shortPrice, Size: &totalShortShares,
|
||||||
Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now,
|
Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
})
|
})
|
||||||
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
TradeID: pos.DBTradeID, Type: "exit", Status: "filled",
|
TradeID: pos.DBTradeID, Type: "exit", Status: "filled",
|
||||||
@@ -1398,29 +1482,61 @@ func (t *Trader) closeLeg(leg *PositionLeg) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if leg.Exchange == ExBitget {
|
if leg.Exchange == ExBitget {
|
||||||
log.Printf("[Order] BG close %s %s | size=%s", side, leg.Coin+"USDT", leg.Size)
|
// Query actual position from exchange before closing — handles partial fills
|
||||||
|
// during IOC entry and scale-ins where leg.Size is stale.
|
||||||
|
posSize, posSizeStr := t.bitget.CheckPosition(leg.Coin + "USDT")
|
||||||
|
if posSize <= 0 {
|
||||||
|
log.Printf("[Trader] BG %s %s: no position to close (already closed)", side, leg.Coin+"USDT")
|
||||||
|
leg.Closed = true
|
||||||
|
leg.ExitTime = time.Now()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Bitget v2 hedge mode: side must match holdSide, not order direction
|
||||||
|
// close long → side=buy, holdSide=long
|
||||||
|
// close short → side=sell, holdSide=short
|
||||||
holdSide := "long"
|
holdSide := "long"
|
||||||
if leg.Side == Short {
|
if leg.Side == Short {
|
||||||
holdSide = "short"
|
holdSide = "short"
|
||||||
}
|
}
|
||||||
resp, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size, "close", holdSide)
|
closeSide := "buy"
|
||||||
|
if leg.Side == Short {
|
||||||
|
closeSide = "sell"
|
||||||
|
}
|
||||||
|
log.Printf("[Order] BG close %s %s | leg.Size=%s actualSize=%s | holdSide=%s", closeSide, leg.Coin+"USDT", leg.Size, posSizeStr, holdSide)
|
||||||
|
resp, err := t.bitget.PlaceMarketOrder(closeSide, leg.Coin+"USDT", posSizeStr, "close", holdSide)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 22002 = no position on exchange (already closed manually or previously)
|
// 22002 = no position on exchange (already closed manually or previously)
|
||||||
if strings.Contains(err.Error(), "22002") {
|
if strings.Contains(err.Error(), "22002") {
|
||||||
log.Printf("[Trader] BG %s %s: already closed (22002)", side, leg.Coin+"USDT")
|
log.Printf("[Trader] BG %s %s: already closed (22002)", closeSide, leg.Coin+"USDT")
|
||||||
} else {
|
} else {
|
||||||
return fmt.Sprintf("%v", err)
|
return fmt.Sprintf("%v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", side, leg.Coin+"USDT", leg.Size, resp)
|
log.Printf("[ExRes] BG close %s %s: size=%s → response=%s", closeSide, leg.Coin+"USDT", posSizeStr, resp)
|
||||||
|
leg.OrderID = resp
|
||||||
|
|
||||||
|
// Fetch actual exit fill price
|
||||||
|
fillPrice, _, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", resp)
|
||||||
|
if fetchErr == nil && fillPrice > 0 {
|
||||||
|
leg.ExitPrice = fillPrice
|
||||||
|
log.Printf("[Fill] BG close %s: actual exitPrice=%.6f", leg.Coin+"USDT", fillPrice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[Order] HL close %s %s | size=%s", side, leg.Coin, leg.Size)
|
log.Printf("[Order] HL close %s %s | size=%s", side, leg.Coin, leg.Size)
|
||||||
resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size)
|
resp, err := t.hyperliquid.PlaceMarketCloseOrder(leg.Coin, leg.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("%v", err)
|
return fmt.Sprintf("%v", err)
|
||||||
}
|
}
|
||||||
log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp)
|
log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp)
|
||||||
|
leg.OrderID = resp
|
||||||
|
|
||||||
|
// Parse actual fill price from HL close response
|
||||||
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
||||||
|
if parseErr == nil && fillPrice > 0 {
|
||||||
|
leg.ExitPrice = fillPrice
|
||||||
|
log.Printf("[Fill] HL close %s: actual exitPrice=%.6f", leg.Coin, fillPrice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
leg.Closed = true
|
leg.Closed = true
|
||||||
leg.ExitTime = time.Now()
|
leg.ExitTime = time.Now()
|
||||||
@@ -1516,6 +1632,7 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
||||||
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
||||||
Fee: &longExitFee, Status: &status, CreatedAt: now,
|
Fee: &longExitFee, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.LongLeg.OrderID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if pos.ShortLeg.Closed {
|
if pos.ShortLeg.Closed {
|
||||||
@@ -1526,6 +1643,7 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
||||||
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
||||||
Fee: &shortExitFee, Status: &status, CreatedAt: now,
|
Fee: &shortExitFee, Status: &status, CreatedAt: now,
|
||||||
|
OrderID: &pos.ShortLeg.OrderID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Save exit system order (idempotent-safe since we always overwrite on retry)
|
// Save exit system order (idempotent-safe since we always overwrite on retry)
|
||||||
@@ -1537,7 +1655,13 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Close trade using previously saved exit metadata
|
// Close trade using previously saved exit metadata
|
||||||
feePct := pos.ExitTotalFees
|
numBatches := 1 + pos.ScaleLevels
|
||||||
|
longEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
shortEntryFeeSum := float64(numBatches) * tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
longExitFeeAmt := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
shortExitFeeAmt := totalshortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
feeEntrySum := longEntryFeeSum + shortEntryFeeSum
|
||||||
|
feeExitSum := longExitFeeAmt + shortExitFeeAmt
|
||||||
t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{
|
t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{
|
||||||
Status: "closed",
|
Status: "closed",
|
||||||
ExitSpread: &pos.ExitDiffPct,
|
ExitSpread: &pos.ExitDiffPct,
|
||||||
@@ -1545,8 +1669,8 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
ShortExit: &pos.ShortLeg.ExitPrice,
|
ShortExit: &pos.ShortLeg.ExitPrice,
|
||||||
LongPnl: &pos.ExitLongPnl,
|
LongPnl: &pos.ExitLongPnl,
|
||||||
ShortPnl: &pos.ExitShortPnl,
|
ShortPnl: &pos.ExitShortPnl,
|
||||||
FeeEntry: &feePct,
|
FeeEntry: &feeEntrySum,
|
||||||
FeeExit: &feePct,
|
FeeExit: &feeExitSum,
|
||||||
NetPnl: &pos.ExitNetPnl,
|
NetPnl: &pos.ExitNetPnl,
|
||||||
AmountUSD: pos.AmountUSD,
|
AmountUSD: pos.AmountUSD,
|
||||||
ScaleCount: pos.ScaleLevels,
|
ScaleCount: pos.ScaleLevels,
|
||||||
@@ -1583,31 +1707,70 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
// placeOrderAt places an order at a specified price (used for scale-in, Issue #2).
|
// placeOrderAt places an order at a specified price (used for scale-in, Issue #2).
|
||||||
// Unlike placeOrder, this doesn't modify the leg's EntryPrice — it places
|
// Unlike placeOrder, this doesn't modify the leg's EntryPrice — it places
|
||||||
// an additional order at the current market price for the same trade amount.
|
// an additional order at the current market price for the same trade amount.
|
||||||
func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, price float64) string {
|
// Returns (error string, actual fee in USD).
|
||||||
|
func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore, price float64) (string, float64) {
|
||||||
if t.cfg.TestMode {
|
if t.cfg.TestMode {
|
||||||
// Mock fill using specified price instead of leg's original entry
|
// Mock fill using specified price instead of leg's original entry
|
||||||
origPrice := leg.EntryPrice
|
origPrice := leg.EntryPrice
|
||||||
leg.EntryPrice = price
|
leg.EntryPrice = price
|
||||||
err := t.mockFill(leg, side, store)
|
err := t.mockFill(leg, side, store)
|
||||||
leg.EntryPrice = origPrice // restore original (entry tracking is per-position, not per-order)
|
leg.EntryPrice = origPrice // restore original (entry tracking is per-position, not per-order)
|
||||||
return err
|
return err, 0
|
||||||
}
|
}
|
||||||
if leg.Exchange == ExBitget {
|
if leg.Exchange == ExBitget {
|
||||||
szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price)
|
szStr := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price)
|
||||||
log.Printf("[Order] BG scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, price, szStr)
|
log.Printf("[Order] BG scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin+"USDT", t.cfg.TradeAmountUSD, price, szStr)
|
||||||
_, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "")
|
oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", szStr, "open", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("BG %s error: %v", side, err)
|
return fmt.Sprintf("BG %s error: %v", side, err), 0
|
||||||
}
|
}
|
||||||
|
leg.OrderID = oid
|
||||||
|
|
||||||
|
// Fetch actual fee and fill price from exchange
|
||||||
|
fillPrice, fee, fetchErr := t.bitget.GetTradeFee(leg.Coin+"USDT", oid)
|
||||||
|
if fetchErr != nil {
|
||||||
|
log.Printf("[Fee] BG scale GetTradeFee warning: %v", fetchErr)
|
||||||
|
fee = 0
|
||||||
|
} else {
|
||||||
|
log.Printf("[Fee] BG scale %s %s: actual fee=$%.6f fillPrice=%.6f", side, leg.Coin+"USDT", fee, fillPrice)
|
||||||
|
if fillPrice > 0 {
|
||||||
|
leg.EntryPrice = fillPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify position actually exists (IOC can succeed without fills)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if posSize, _ := t.bitget.CheckPosition(leg.Coin + "USDT"); posSize <= 0 {
|
||||||
|
log.Printf("[Trader] BG scale %s %s: IOC accepted but no position created (zero fill)", side, leg.Coin+"USDT")
|
||||||
|
return fmt.Sprintf("BG scale %s zero fill (no position)", side), fee
|
||||||
|
}
|
||||||
|
return "", fee
|
||||||
} else {
|
} else {
|
||||||
szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price)
|
szStr := t.hyperliquid.GetSize(leg.Coin, t.cfg.TradeAmountUSD, price)
|
||||||
log.Printf("[Order] HL scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, price, szStr)
|
log.Printf("[Order] HL scale %s %s | amountUSD=%.2f price=%.6f size=%s", side, leg.Coin, t.cfg.TradeAmountUSD, price, szStr)
|
||||||
_, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr)
|
oid, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, szStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("HL %s error: %v", side, err)
|
return fmt.Sprintf("HL %s error: %v", side, err), 0
|
||||||
}
|
}
|
||||||
|
leg.OrderID = oid
|
||||||
|
|
||||||
|
// Parse actual fill price from HL response
|
||||||
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(oid)
|
||||||
|
if parseErr == nil && fillPrice > 0 {
|
||||||
|
leg.EntryPrice = fillPrice
|
||||||
|
log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate fee from HL response
|
||||||
|
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(oid, takerFees[ExHyperLiquid])
|
||||||
|
if fetchErr != nil {
|
||||||
|
log.Printf("[Fee] HL scale EstimateFeeFromResponse warning: %v", fetchErr)
|
||||||
|
fee = 0
|
||||||
|
} else {
|
||||||
|
log.Printf("[Fee] HL scale %s %s: fee=$%.6f", side, leg.Coin, fee)
|
||||||
|
}
|
||||||
|
return "", fee
|
||||||
}
|
}
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage.
|
// mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage.
|
||||||
@@ -1802,6 +1965,7 @@ func (t *Trader) restoreOpenPositions() {
|
|||||||
pos.ShortLeg.EntryPrice = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
pos.ShortLeg.EntryPrice = weightedAvgPrice(pos.ShortEntryPrices, t.cfg.TradeAmountUSD)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pos.DBTradeID = tr.ID
|
||||||
t.positions[tr.Coin] = pos
|
t.positions[tr.Coin] = pos
|
||||||
// Prevent immediate re-trading of the same coin
|
// Prevent immediate re-trading of the same coin
|
||||||
t.lastTradeTime[tr.Coin] = tr.OpenedAt
|
t.lastTradeTime[tr.Coin] = tr.OpenedAt
|
||||||
@@ -1836,6 +2000,25 @@ func (t *Trader) blacklistCoin(pos *ArbPosition, bgP, hlP, diffPct float64, noti
|
|||||||
pos.ShortLeg.ExitPrice = shortCurrent
|
pos.ShortLeg.ExitPrice = shortCurrent
|
||||||
pos.ExitReasonText = "黑名单强平"
|
pos.ExitReasonText = "黑名单强平"
|
||||||
|
|
||||||
|
// Pre-compute per-exchange PnL/fees for retryClose
|
||||||
|
numBatchesBlack := 1 + pos.ScaleLevels
|
||||||
|
pos.ExitLongPnlUSD = longPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD
|
||||||
|
pos.ExitShortPnlUSD = shortPnl / 100 * float64(numBatchesBlack) * t.cfg.TradeAmountUSD
|
||||||
|
{
|
||||||
|
totalLongSharesBlack := 0.0
|
||||||
|
for _, p := range pos.LongEntryPrices {
|
||||||
|
totalLongSharesBlack += t.cfg.TradeAmountUSD / p
|
||||||
|
}
|
||||||
|
totalShortSharesBlack := 0.0
|
||||||
|
for _, p := range pos.ShortEntryPrices {
|
||||||
|
totalShortSharesBlack += t.cfg.TradeAmountUSD / p
|
||||||
|
}
|
||||||
|
pos.ExitLongFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 +
|
||||||
|
totalLongSharesBlack*longCurrent*takerFees[pos.LongLeg.Exchange]/100
|
||||||
|
pos.ExitShortFeeUSD = float64(numBatchesBlack)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 +
|
||||||
|
totalShortSharesBlack*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100
|
||||||
|
}
|
||||||
|
|
||||||
// Convergence label
|
// Convergence label
|
||||||
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
|
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
|
||||||
if convergedPct < -10 {
|
if convergedPct < -10 {
|
||||||
|
|||||||
Reference in New Issue
Block a user