Fix 3 arbitrage logic issues from code review

Issue #1 (critical): reProfit false positive on price reversal
  executeEntry used CalcNetProfit which auto-swaps prices when
  hlP < bgP. If prices flipped between scan and execution, reProfit
  reported positive even when direction was now wrong.
  Fix: use netProfit() with explicit direction + verify spread
  direction hasn't flipped (hlP <= bgP prevents BG->HL when
  HL is no longer more expensive).

Issue #2 (medium): Scale-in was paper-only, didn't place orders
  Now actually places additional orders on both legs via new
  placeOrderAt(). Test mode uses mock fills. Live mode sends
  real API orders. AmountUSD properly tracks total deployed
  capital. Partial fill handled gracefully (don't close main leg).

Issue #3 (minor): closeLeg missing ExitTime on mock mode
This commit is contained in:
jackyu66git
2026-05-03 18:19:25 +08:00
parent 931855e1f5
commit ab48e207a5
+68 -12
View File
@@ -159,7 +159,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
diffPct := (highP - lowP) / lowP * 100
// Check scale-in: if spread widened enough, add more
t.checkScaleIn(pos, bgP, hlP, diffPct, snap)
t.checkScaleIn(pos, bgP, hlP, diffPct, store)
// Check exit: if spread converged, take profit
t.checkExit(pos, bgP, hlP, diffPct, notifier)
@@ -206,13 +206,25 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
return
}
reProfit := exchange.CalcNetProfit(bgP, hlP,
makerFees[ExBitget], makerFees[ExHyperLiquid],
makerFees[ExHyperLiquid], makerFees[ExBitget])
// Issue #1: reProfit must use actual direction — CalcNetProfit auto-swaps on reversal!
var reProfit float64
if opp.BuyEx == ExBitget {
reProfit = netProfit(bgP, hlP, makerFees[ExBitget], makerFees[ExHyperLiquid])
} else {
reProfit = netProfit(hlP, bgP, makerFees[ExHyperLiquid], makerFees[ExBitget])
}
if reProfit < t.cfg.TradeThreshold {
return
}
// Issue #1: Verify spread direction hasn't flipped since scan
if opp.BuyEx == ExBitget && hlP <= bgP {
return // reversed: HL no longer more expensive than BG
}
if opp.BuyEx == ExHyperLiquid && bgP <= hlP {
return // reversed: BG no longer more expensive than HL
}
pos := &ArbPosition{
Coin: opp.Coin,
AmountUSD: t.cfg.TradeAmountUSD,
@@ -293,7 +305,8 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
}
// checkScaleIn adds more position when spread widens further.
func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, snap map[string]map[string]float64) {
// Issues actual orders on both legs to increase notional exposure (Issue #2).
func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, store *PriceStore) {
if pos.Status != "open" {
return
}
@@ -320,18 +333,33 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, snap
return
}
// Scale in: add same amount again
// Place additional orders on both legs to increase position size
// Use the current (wider) prices for the new orders
longPrice := bgP
shortPrice := hlP
if pos.LongLeg.Exchange == ExHyperLiquid {
longPrice, shortPrice = hlP, bgP
}
if err := t.placeOrderAt(pos.LongLeg, "buy", store, longPrice); err != "" {
log.Printf("[Trader] %s: Scale-in long failed: %s", pos.Coin, err)
return
}
time.Sleep(300 * time.Millisecond)
if err := t.placeOrderAt(pos.ShortLeg, "sell", store, shortPrice); err != "" {
log.Printf("[Trader] %s: Scale-in short failed: %s — position partially scaled (long only)", pos.Coin, err)
// 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
// decides what to do. This is a partial fill scenario.
return
}
pos.ScaleLevels++
pos.LastScaleAt = time.Now()
pos.AmountUSD += t.cfg.TradeAmountUSD
log.Printf("[Trader] %s: Scale-in #%d | spread=%.4f%% (entry=%.4f%%) | total=$%.0f",
log.Printf("[Trader] %s: Scale-in #%d executed | spread=%.4f%% (entry=%.4f%%) | total=$%.0f",
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
// No need to place new orders — the existing position size stays the same
// In perpetual futures, we don't physically hold more units; the notional value
// was already set at entry. The "scale" here tracks the widened spread.
// Actual position sizing is handled by the API at entry.
}
// checkExit closes position when spread converges.
@@ -529,6 +557,34 @@ func (t *Trader) closeLeg(leg *PositionLeg) string {
return ""
}
// 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
// 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 {
if t.cfg.TestMode {
// Mock fill using specified price instead of leg's original entry
origPrice := leg.EntryPrice
leg.EntryPrice = price
err := t.mockFill(leg, side, store)
leg.EntryPrice = origPrice // restore original (entry tracking is per-position, not per-order)
return err
}
if leg.Exchange == ExBitget {
size := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, price)
_, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", size)
if err != nil {
return fmt.Sprintf("BG %s error: %v", side, err)
}
} else {
size := exchange.GetHLSize(leg.Coin, t.cfg.TradeAmountUSD, price)
_, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size)
if err != nil {
return fmt.Sprintf("HL %s error: %v", side, err)
}
}
return ""
}
// mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage.
// Falls back to fixed MOCK_SLIPPAGE_PCT if no spread data available.
func (t *Trader) mockFill(leg *PositionLeg, side string, store *PriceStore) string {