diff --git a/trader.go b/trader.go index 8b9a2b8..2fdf3d0 100644 --- a/trader.go +++ b/trader.go @@ -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 {