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:
@@ -159,7 +159,7 @@ func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
|||||||
diffPct := (highP - lowP) / lowP * 100
|
diffPct := (highP - lowP) / lowP * 100
|
||||||
|
|
||||||
// Check scale-in: if spread widened enough, add more
|
// 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
|
// Check exit: if spread converged, take profit
|
||||||
t.checkExit(pos, bgP, hlP, diffPct, notifier)
|
t.checkExit(pos, bgP, hlP, diffPct, notifier)
|
||||||
@@ -206,13 +206,25 @@ func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reProfit := exchange.CalcNetProfit(bgP, hlP,
|
// Issue #1: reProfit must use actual direction — CalcNetProfit auto-swaps on reversal!
|
||||||
makerFees[ExBitget], makerFees[ExHyperLiquid],
|
var reProfit float64
|
||||||
makerFees[ExHyperLiquid], makerFees[ExBitget])
|
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 {
|
if reProfit < t.cfg.TradeThreshold {
|
||||||
return
|
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{
|
pos := &ArbPosition{
|
||||||
Coin: opp.Coin,
|
Coin: opp.Coin,
|
||||||
AmountUSD: t.cfg.TradeAmountUSD,
|
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.
|
// 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" {
|
if pos.Status != "open" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,18 +333,33 @@ func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, snap
|
|||||||
return
|
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.ScaleLevels++
|
||||||
pos.LastScaleAt = time.Now()
|
pos.LastScaleAt = time.Now()
|
||||||
pos.AmountUSD += t.cfg.TradeAmountUSD
|
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)
|
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.
|
// checkExit closes position when spread converges.
|
||||||
@@ -529,6 +557,34 @@ func (t *Trader) closeLeg(leg *PositionLeg) string {
|
|||||||
return ""
|
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.
|
// mockFill simulates order execution. Uses stored bid/ask spread for realistic slippage.
|
||||||
// Falls back to fixed MOCK_SLIPPAGE_PCT if no spread data available.
|
// Falls back to fixed MOCK_SLIPPAGE_PCT if no spread data available.
|
||||||
func (t *Trader) mockFill(leg *PositionLeg, side string, store *PriceStore) string {
|
func (t *Trader) mockFill(leg *PositionLeg, side string, store *PriceStore) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user