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
735 lines
20 KiB
Go
735 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"exchange-monitor/db"
|
|
"exchange-monitor/exchange"
|
|
)
|
|
|
|
// PositionSide indicates the direction of a position.
|
|
type PositionSide string
|
|
|
|
const (
|
|
Long PositionSide = "long"
|
|
Short PositionSide = "short"
|
|
)
|
|
|
|
// PositionLeg represents one leg of an arbitrage position.
|
|
type PositionLeg struct {
|
|
Coin string
|
|
Exchange string
|
|
Side PositionSide
|
|
Size string // contract size
|
|
EntryTime time.Time
|
|
EntryPrice float64
|
|
OrderID string
|
|
Closed bool
|
|
ExitPrice float64
|
|
ExitTime time.Time
|
|
}
|
|
|
|
// ArbPosition represents a scaled-in arbitrage position.
|
|
type ArbPosition struct {
|
|
Coin string
|
|
Direction string // "BG->HL" or "HL->BG"
|
|
LongLeg *PositionLeg
|
|
ShortLeg *PositionLeg
|
|
AmountUSD float64 // total amount deployed
|
|
|
|
EntrySpread float64 // spread % at entry (high price - low price) / low * 100
|
|
|
|
// Scaling levels
|
|
ScaleLevels int // how many times we've scaled in (0 = initial)
|
|
LastScaleAt time.Time // when we last scaled in
|
|
StartedAt time.Time
|
|
ExitedAt time.Time
|
|
Status string // "open", "closed"
|
|
RealizedPnl float64
|
|
ErrorLog string
|
|
}
|
|
|
|
// Trader handles scalable arbitrage between Bitget and HyperLiquid.
|
|
type Trader struct {
|
|
cfg *Config
|
|
bitget *exchange.BitgetTrade
|
|
hyperliquid *exchange.HyperLiquidTrade
|
|
|
|
db *db.DB
|
|
mu sync.Mutex
|
|
positions map[string]*ArbPosition // coin -> position
|
|
lastTradeTime map[string]time.Time
|
|
closedTrades []TradeRecord // history of closed trades
|
|
|
|
OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push
|
|
}
|
|
|
|
// TradeRecord stores a finalized trade for stats tracking.
|
|
type TradeRecord struct {
|
|
Coin string
|
|
Direction string
|
|
EntrySpread float64
|
|
ExitSpread float64
|
|
PnlPct float64
|
|
Convergence string // "收敛", "发散", "持平"
|
|
Reason string // exit reason
|
|
Duration string
|
|
OpenedAt time.Time
|
|
ClosedAt time.Time
|
|
ScaleLevels int
|
|
AmountUSD float64
|
|
}
|
|
|
|
func NewTrader(cfg *Config, database *db.DB) *Trader {
|
|
var bt *exchange.BitgetTrade
|
|
if cfg.BitgetAPIKey != "" {
|
|
bt = exchange.NewBitgetTrade(cfg.BitgetAPIKey, cfg.BitgetAPISecret, cfg.BitgetPassphrase)
|
|
}
|
|
hl, _ := exchange.NewHyperLiquidTrade(cfg.HLPrivateKey, cfg.HLAddress)
|
|
|
|
t := &Trader{
|
|
cfg: cfg,
|
|
db: database,
|
|
bitget: bt,
|
|
hyperliquid: hl,
|
|
positions: make(map[string]*ArbPosition),
|
|
lastTradeTime: make(map[string]time.Time),
|
|
}
|
|
|
|
// Restore open positions from DB on restart
|
|
if database != nil {
|
|
t.restoreOpenPositions()
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
func (t *Trader) IsConfigured() bool {
|
|
switch {
|
|
case t.cfg.TestMode:
|
|
return true
|
|
case t.cfg.TradeEnabled && t.bitget != nil && t.hyperliquid != nil && t.hyperliquid.IsConfigured():
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *Trader) ModeLabel() string {
|
|
if t.cfg.TestMode {
|
|
return "SIMULATION"
|
|
}
|
|
return "LIVE"
|
|
}
|
|
|
|
// Tick is called every scanner cycle — checks scaling and exit.
|
|
func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
|
if !t.IsConfigured() {
|
|
return
|
|
}
|
|
snap := store.GetAll()
|
|
|
|
t.mu.Lock()
|
|
positions := make([]*ArbPosition, 0, len(t.positions))
|
|
for _, pos := range t.positions {
|
|
positions = append(positions, pos)
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
for _, pos := range positions {
|
|
exMap := snap[pos.Coin]
|
|
if exMap == nil {
|
|
continue
|
|
}
|
|
bgP := exMap[ExBitget]
|
|
hlP := exMap[ExHyperLiquid]
|
|
if bgP <= 0 || hlP <= 0 {
|
|
continue
|
|
}
|
|
|
|
// Calc current spread
|
|
var lowP, highP float64
|
|
if pos.Direction == "BG->HL" {
|
|
lowP, highP = bgP, hlP
|
|
} else {
|
|
lowP, highP = hlP, bgP
|
|
}
|
|
diffPct := (highP - lowP) / lowP * 100
|
|
|
|
// Check scale-in: if spread widened enough, add more
|
|
t.checkScaleIn(pos, bgP, hlP, diffPct, store)
|
|
|
|
// Check exit: if spread converged, take profit
|
|
t.checkExit(pos, bgP, hlP, diffPct, notifier)
|
|
}
|
|
}
|
|
|
|
// TryEntry opens initial position when threshold is met.
|
|
func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier) bool {
|
|
if !t.IsConfigured() {
|
|
return false
|
|
}
|
|
if (opp.BuyEx != ExBitget && opp.BuyEx != ExHyperLiquid) ||
|
|
(opp.SellEx != ExBitget && opp.SellEx != ExHyperLiquid) {
|
|
return false
|
|
}
|
|
if opp.NetProfit < t.cfg.TradeThreshold {
|
|
return false
|
|
}
|
|
|
|
t.mu.Lock()
|
|
if _, exists := t.positions[opp.Coin]; exists {
|
|
t.mu.Unlock()
|
|
return false
|
|
}
|
|
if lastTime, ok := t.lastTradeTime[opp.Coin]; ok && time.Since(lastTime) < 30*time.Second {
|
|
t.mu.Unlock()
|
|
return false
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
go t.executeEntry(opp, store, notifier)
|
|
return true
|
|
}
|
|
|
|
func (t *Trader) executeEntry(opp *ArbOpportunity, store *PriceStore, notifier *Notifier) {
|
|
snap := store.GetAll()
|
|
exMap := snap[opp.Coin]
|
|
if exMap == nil {
|
|
return
|
|
}
|
|
bgP := exMap[ExBitget]
|
|
hlP := exMap[ExHyperLiquid]
|
|
if bgP <= 0 || hlP <= 0 {
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
StartedAt: time.Now(),
|
|
Status: "open",
|
|
ScaleLevels: 0,
|
|
}
|
|
|
|
entrySpread := (hlP - bgP) / bgP * 100
|
|
if opp.BuyEx == ExBitget {
|
|
pos.Direction = "BG->HL"
|
|
pos.EntrySpread = entrySpread // positive when hlP > bgP
|
|
pos.LongLeg = &PositionLeg{
|
|
Coin: opp.Coin, Exchange: ExBitget, Side: Long,
|
|
EntryPrice: bgP, EntryTime: time.Now(),
|
|
}
|
|
pos.ShortLeg = &PositionLeg{
|
|
Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Short,
|
|
EntryPrice: hlP, EntryTime: time.Now(),
|
|
}
|
|
} else {
|
|
pos.Direction = "HL->BG"
|
|
pos.EntrySpread = (bgP - hlP) / hlP * 100 // positive when bgP > hlP
|
|
pos.LongLeg = &PositionLeg{
|
|
Coin: opp.Coin, Exchange: ExHyperLiquid, Side: Long,
|
|
EntryPrice: hlP, EntryTime: time.Now(),
|
|
}
|
|
pos.ShortLeg = &PositionLeg{
|
|
Coin: opp.Coin, Exchange: ExBitget, Side: Short,
|
|
EntryPrice: bgP, EntryTime: time.Now(),
|
|
}
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.positions[opp.Coin] = pos
|
|
t.mu.Unlock()
|
|
|
|
// Execute both legs
|
|
if err := t.placeOrder(pos.LongLeg, "buy", store); err != "" {
|
|
t.cleanup(pos.Coin)
|
|
return
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
if err := t.placeOrder(pos.ShortLeg, "sell", store); err != "" {
|
|
t.closeLeg(pos.LongLeg)
|
|
t.cleanup(pos.Coin)
|
|
return
|
|
}
|
|
|
|
pos.LastScaleAt = time.Now()
|
|
|
|
log.Printf("[Trader] %s: Opened %s | Long %s @ %.2f Short %s @ %.2f | $%.0f",
|
|
pos.Coin, pos.Direction, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice,
|
|
pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, t.cfg.TradeAmountUSD)
|
|
|
|
diff := (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
|
notifier.Send(fmt.Sprintf(
|
|
"<b>[开仓]</b> %s/USDT %s\n"+
|
|
" 多 %s @ %.2f\n"+
|
|
" 空 %s @ %.2f\n"+
|
|
" 价差: %+.4f%%\n"+
|
|
" 规模: $%.0f\n",
|
|
pos.Coin, pos.Direction,
|
|
pos.LongLeg.Exchange, pos.LongLeg.EntryPrice,
|
|
pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice,
|
|
diff, t.cfg.TradeAmountUSD))
|
|
|
|
// P3-4: real-time trade event push
|
|
if t.OnTradeEvent != nil {
|
|
t.OnTradeEvent("trade_open", map[string]interface{}{
|
|
"coin": pos.Coin,
|
|
"direction": pos.Direction,
|
|
"entry_spread": diff,
|
|
"amount_usd": t.cfg.TradeAmountUSD,
|
|
"time": time.Now().Format("15:04:05"),
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkScaleIn adds more position when spread widens further.
|
|
// 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
|
|
}
|
|
|
|
// Scale-in threshold: every +0.10% beyond entry
|
|
var entryDiff float64
|
|
if pos.Direction == "BG->HL" {
|
|
entryDiff = (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
|
} else {
|
|
entryDiff = (pos.ShortLeg.EntryPrice - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
|
if entryDiff < 0 {
|
|
entryDiff = -entryDiff
|
|
}
|
|
}
|
|
|
|
scaleStep := 0.10 // add every 0.10% wider
|
|
nextLevel := float64(pos.ScaleLevels+1) * scaleStep
|
|
if diffPct < entryDiff+nextLevel {
|
|
return
|
|
}
|
|
|
|
// Cooldown: at least 5 seconds between scales
|
|
if time.Since(pos.LastScaleAt) < 5*time.Second {
|
|
return
|
|
}
|
|
|
|
// 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 executed | spread=%.4f%% (entry=%.4f%%) | total=$%.0f",
|
|
pos.Coin, pos.ScaleLevels, diffPct, entryDiff, pos.AmountUSD)
|
|
}
|
|
|
|
// checkExit closes position when spread converges.
|
|
func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier *Notifier) {
|
|
if pos.Status != "open" {
|
|
return
|
|
}
|
|
|
|
// Exit when spread converges to near zero (<= 0.02%)
|
|
// Or if held too long (30 min timeout)
|
|
elapsed := time.Since(pos.StartedAt)
|
|
|
|
shouldExit := false
|
|
exitReason := ""
|
|
|
|
if diffPct <= 0.02 {
|
|
shouldExit = true
|
|
exitReason = "价差收敛,止盈平仓"
|
|
}
|
|
|
|
if elapsed > 30*time.Minute {
|
|
shouldExit = true
|
|
exitReason = "超时平仓"
|
|
}
|
|
|
|
if !shouldExit {
|
|
return
|
|
}
|
|
|
|
// Calculate P&L
|
|
var longCurrent, shortCurrent float64
|
|
if pos.LongLeg.Exchange == ExBitget {
|
|
longCurrent, shortCurrent = bgP, hlP
|
|
} else {
|
|
longCurrent, shortCurrent = hlP, bgP
|
|
}
|
|
|
|
longPnl := (longCurrent - pos.LongLeg.EntryPrice) / pos.LongLeg.EntryPrice * 100
|
|
shortPnl := (pos.ShortLeg.EntryPrice - shortCurrent) / pos.ShortLeg.EntryPrice * 100
|
|
totalFees := 2 * (makerFees[ExBitget] + makerFees[ExHyperLiquid]) // 开仓 + 平仓手续费
|
|
netPnl := longPnl + shortPnl - totalFees
|
|
|
|
// Convergence analysis
|
|
convergedPct := (pos.EntrySpread - diffPct) / pos.EntrySpread * 100
|
|
convergenceLabel := "价差收敛"
|
|
if convergedPct < -10 {
|
|
convergenceLabel = "价差发散"
|
|
} else if convergedPct < 10 {
|
|
convergenceLabel = "价差持平"
|
|
}
|
|
|
|
log.Printf("[Trader] %s: %s | entry=%.4f%% exit=%.4f%% conv=%.1f%% %s | long=%.4f%% short=%.4f%% net=%.4f%% | scales=%d held=%s",
|
|
pos.Coin, exitReason, pos.EntrySpread, diffPct, convergedPct, convergenceLabel,
|
|
longPnl, shortPnl, netPnl, pos.ScaleLevels, elapsed.Round(time.Second).String())
|
|
|
|
pos.LongLeg.ExitPrice = longCurrent
|
|
pos.ShortLeg.ExitPrice = shortCurrent
|
|
|
|
closeErr := t.closeBothLegs(pos)
|
|
|
|
pos.RealizedPnl = netPnl
|
|
pos.ExitedAt = time.Now()
|
|
pos.Status = "closed"
|
|
|
|
// Save trade record for stats
|
|
record := TradeRecord{
|
|
Coin: pos.Coin,
|
|
Direction: pos.Direction,
|
|
EntrySpread: pos.EntrySpread,
|
|
ExitSpread: diffPct,
|
|
PnlPct: netPnl,
|
|
Convergence: convergenceLabel,
|
|
Reason: exitReason,
|
|
Duration: elapsed.Round(time.Second).String(),
|
|
OpenedAt: pos.StartedAt,
|
|
ClosedAt: pos.ExitedAt,
|
|
ScaleLevels: pos.ScaleLevels,
|
|
AmountUSD: pos.AmountUSD,
|
|
}
|
|
|
|
t.mu.Lock()
|
|
delete(t.positions, pos.Coin)
|
|
t.lastTradeTime[pos.Coin] = time.Now()
|
|
t.closedTrades = append(t.closedTrades, record)
|
|
t.mu.Unlock()
|
|
|
|
// Persist to SQLite
|
|
if t.db != nil {
|
|
go t.persistTrade(pos, diffPct, convergenceLabel, exitReason, netPnl, longPnl, shortPnl, totalFees)
|
|
}
|
|
|
|
msg := fmt.Sprintf(
|
|
"<b>[平仓]</b> %s/USDT %s\n"+
|
|
" 持仓: %s 加仓: %d次\n"+
|
|
" 总规模: $%.0f\n"+
|
|
" 价差: %.4f%% → %.4f%% (%s)\n"+
|
|
" 多: %+.4f%% (%s %.2f → %.2f)\n"+
|
|
" 空: %+.4f%% (%s %.2f → %.2f)\n"+
|
|
" 手续费: %.4f%%\n"+
|
|
" 净收益: <b>%+.4f%%</b>\n"+
|
|
" 原因: %s\n",
|
|
pos.Coin, pos.Direction,
|
|
elapsed.Round(time.Second).String(), pos.ScaleLevels,
|
|
pos.AmountUSD,
|
|
pos.EntrySpread, diffPct, convergenceLabel,
|
|
longPnl, pos.LongLeg.Exchange, pos.LongLeg.EntryPrice, longCurrent,
|
|
shortPnl, pos.ShortLeg.Exchange, pos.ShortLeg.EntryPrice, shortCurrent,
|
|
totalFees, netPnl, exitReason,
|
|
)
|
|
if closeErr != "" {
|
|
msg += fmt.Sprintf(" 平仓异常: %s\n", closeErr)
|
|
}
|
|
notifier.Send(msg)
|
|
|
|
// P3-4: real-time trade event push
|
|
if t.OnTradeEvent != nil {
|
|
t.OnTradeEvent("trade_close", map[string]interface{}{
|
|
"coin": pos.Coin,
|
|
"direction": pos.Direction,
|
|
"entry_spread": pos.EntrySpread,
|
|
"exit_spread": diffPct,
|
|
"pnl_pct": netPnl,
|
|
"convergence": convergenceLabel,
|
|
"duration": elapsed.Round(time.Second).String(),
|
|
"time": time.Now().Format("15:04:05"),
|
|
})
|
|
}
|
|
}
|
|
|
|
func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) string {
|
|
if t.cfg.TestMode {
|
|
return t.mockFill(leg, side, store)
|
|
}
|
|
if leg.Exchange == ExBitget {
|
|
size := exchange.GetBitgetSize(leg.Coin+"USDT", t.cfg.TradeAmountUSD, leg.EntryPrice)
|
|
oid, err := t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", size)
|
|
if err != nil {
|
|
return fmt.Sprintf("BG %s error: %v", side, err)
|
|
}
|
|
leg.Size = size
|
|
leg.OrderID = oid
|
|
} else {
|
|
size := exchange.GetHLSize(leg.Coin, t.cfg.TradeAmountUSD, leg.EntryPrice)
|
|
resp, err := t.hyperliquid.PlaceMarketOrder(leg.Coin, side, size)
|
|
if err != nil {
|
|
return fmt.Sprintf("HL %s error: %v", side, err)
|
|
}
|
|
leg.Size = size
|
|
leg.OrderID = resp
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (t *Trader) closeBothLegs(pos *ArbPosition) string {
|
|
errs := ""
|
|
if !pos.LongLeg.Closed {
|
|
if e := t.closeLeg(pos.LongLeg); e != "" {
|
|
errs += "long:" + e + "; "
|
|
}
|
|
}
|
|
if !pos.ShortLeg.Closed {
|
|
if e := t.closeLeg(pos.ShortLeg); e != "" {
|
|
errs += "short:" + e + "; "
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func (t *Trader) closeLeg(leg *PositionLeg) string {
|
|
if leg.Closed {
|
|
return ""
|
|
}
|
|
side := "sell"
|
|
if leg.Side == Short {
|
|
side = "buy"
|
|
}
|
|
|
|
if t.cfg.TestMode {
|
|
leg.Closed = true
|
|
leg.ExitTime = time.Now()
|
|
return ""
|
|
}
|
|
|
|
var err error
|
|
if leg.Exchange == ExBitget {
|
|
_, err = t.bitget.PlaceMarketOrder(side, leg.Coin+"USDT", leg.Size)
|
|
} else {
|
|
_, err = t.hyperliquid.PlaceMarketOrder(leg.Coin, side, leg.Size)
|
|
}
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", err)
|
|
}
|
|
leg.Closed = true
|
|
leg.ExitTime = time.Now()
|
|
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 {
|
|
spreadPct := t.cfg.MockSlippagePct // default fallback
|
|
|
|
// Try to get actual spread from store
|
|
if s := store.GetSpread(leg.Coin, leg.Exchange); s > 0 {
|
|
spreadPct = s
|
|
}
|
|
|
|
slippage := spreadPct * 0.01 * leg.EntryPrice
|
|
fillPrice := leg.EntryPrice
|
|
if side == "buy" {
|
|
fillPrice += slippage
|
|
} else {
|
|
fillPrice -= slippage
|
|
}
|
|
|
|
leg.EntryPrice = fillPrice
|
|
leg.Size = "mock"
|
|
leg.OrderID = "mock-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
leg.Closed = false
|
|
return ""
|
|
}
|
|
|
|
func (t *Trader) cleanup(coin string) {
|
|
t.mu.Lock()
|
|
delete(t.positions, coin)
|
|
t.lastTradeTime[coin] = time.Now()
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *Trader) GetOpenPositions() []*ArbPosition {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
r := make([]*ArbPosition, 0, len(t.positions))
|
|
for _, p := range t.positions {
|
|
r = append(r, p)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// GetClosedStats returns convergence stats from all closed trades.
|
|
func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for _, tr := range t.closedTrades {
|
|
total++
|
|
switch tr.Convergence {
|
|
case "价差收敛":
|
|
converged++
|
|
case "价差发散":
|
|
diverged++
|
|
default:
|
|
flat++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetClosedTrades returns the full closed trade history.
|
|
func (t *Trader) GetClosedTrades() []TradeRecord {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
r := make([]TradeRecord, len(t.closedTrades))
|
|
copy(r, t.closedTrades)
|
|
return r
|
|
}
|
|
|
|
// persistTrade saves a completed trade to SQLite.
|
|
func (t *Trader) persistTrade(pos *ArbPosition, exitSpread float64, convergence, exitReason string, netPnl, longPnl, shortPnl, totalFees float64) {
|
|
var entrySpread, fe float64
|
|
if pos.LongLeg != nil {
|
|
entrySpread = pos.EntrySpread
|
|
}
|
|
fe = totalFees / 2 // split into entry/exit halves
|
|
|
|
now := time.Now()
|
|
dbTrade := &db.TradeRecord{
|
|
Coin: pos.Coin,
|
|
Direction: pos.Direction,
|
|
Status: "closed",
|
|
EntrySpread: &entrySpread,
|
|
ExitSpread: &exitSpread,
|
|
LongExchange: pos.LongLeg.Exchange,
|
|
ShortExchange: pos.ShortLeg.Exchange,
|
|
LongEntry: &pos.LongLeg.EntryPrice,
|
|
LongExit: &pos.LongLeg.ExitPrice,
|
|
ShortEntry: &pos.ShortLeg.EntryPrice,
|
|
ShortExit: &pos.ShortLeg.ExitPrice,
|
|
LongPnl: &longPnl,
|
|
ShortPnl: &shortPnl,
|
|
FeeEntry: &fe,
|
|
FeeExit: &fe,
|
|
NetPnl: &netPnl,
|
|
AmountUSD: pos.AmountUSD,
|
|
ScaleCount: pos.ScaleLevels,
|
|
ExitReason: &exitReason,
|
|
Convergence: &convergence,
|
|
OpenedAt: pos.StartedAt,
|
|
ClosedAt: &now,
|
|
}
|
|
if _, err := t.db.SaveTrade(dbTrade); err != nil {
|
|
log.Printf("[Trader] Failed to save trade to DB: %v", err)
|
|
}
|
|
}
|
|
|
|
// restoreOpenPositions loads open trades from DB and recreates their positions.
|
|
func (t *Trader) restoreOpenPositions() {
|
|
openTrades, err := t.db.GetOpenTrades()
|
|
if err != nil {
|
|
log.Printf("[Trader] Failed to load open trades: %v", err)
|
|
return
|
|
}
|
|
for i := range openTrades {
|
|
tr := &openTrades[i]
|
|
// Recreate position structure from DB record
|
|
pos := &ArbPosition{
|
|
Coin: tr.Coin,
|
|
Direction: tr.Direction,
|
|
AmountUSD: tr.AmountUSD,
|
|
EntrySpread: *tr.EntrySpread,
|
|
ScaleLevels: tr.ScaleCount,
|
|
LastScaleAt: tr.OpenedAt, // B#3: prevent immediate scale-in bypass
|
|
StartedAt: tr.OpenedAt,
|
|
Status: "open",
|
|
}
|
|
if tr.LongEntry != nil {
|
|
pos.LongLeg = &PositionLeg{
|
|
Coin: tr.Coin, Exchange: tr.LongExchange, Side: Long,
|
|
EntryPrice: *tr.LongEntry, EntryTime: tr.OpenedAt,
|
|
}
|
|
}
|
|
if tr.ShortEntry != nil {
|
|
pos.ShortLeg = &PositionLeg{
|
|
Coin: tr.Coin, Exchange: tr.ShortExchange, Side: Short,
|
|
EntryPrice: *tr.ShortEntry, EntryTime: tr.OpenedAt,
|
|
}
|
|
}
|
|
t.positions[tr.Coin] = pos
|
|
// Prevent immediate re-trading of the same coin
|
|
t.lastTradeTime[tr.Coin] = tr.OpenedAt
|
|
}
|
|
if len(openTrades) > 0 {
|
|
log.Printf("[Trader] Restored %d open positions from DB", len(openTrades))
|
|
}
|
|
}
|