Initial commit
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
# 交易所监控 + 自动套利
|
||||||
|
# 复制为 .env 并填入实际值
|
||||||
|
|
||||||
|
# Telegram 推送
|
||||||
|
TELEGRAM_BOT_TOKEN=***
|
||||||
|
TELEGRAM_CHAT_ID=你的聊天ID
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 自动交易开关 (设置为 1 启用)
|
||||||
|
TRADE_ENABLED=0
|
||||||
|
|
||||||
|
# 交易参数
|
||||||
|
TRADE_THRESHOLD=0.15 # 最低套利利润率 (%)
|
||||||
|
TRADE_AMOUNT_USD=10 # 每腿金额 (USDT)
|
||||||
|
TRADE_COOLDOWN_MS=30000 # 同一币种套利冷却 (毫秒)
|
||||||
|
|
||||||
|
# Bitget API (需开通合约API)
|
||||||
|
BITGET_API_KEY=***
|
||||||
|
BITGET_API_SECRET=***
|
||||||
|
BITGET_PASSPHRASE=你的密码短语
|
||||||
|
|
||||||
|
# HyperLiquid API (钱包私钥)
|
||||||
|
HL_PRIVATE_KEY=你的ed25519私钥(hex)
|
||||||
|
HL_ADDRESS=你的钱包地址
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 以下为 ema-monitor 使用的参数 (保持不变)
|
||||||
|
DATA_API_BASE=http://localhost:80
|
||||||
|
SYMBOL=BTC/USDT:USDT
|
||||||
|
FETCH_LIMIT_BASE=8000
|
||||||
|
POLL_INTERVAL=10
|
||||||
|
PROXIMITY_THRESHOLD_PCT=0.15
|
||||||
|
ALERT_COOLDOWN=3600
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 测试模式 (模拟交易,不需要真实 API Key)
|
||||||
|
# TEST_MODE=true 时,TRADE_ENABLED 被忽略
|
||||||
|
TEST_MODE=false
|
||||||
|
MOCK_SLIPPAGE_PCT=0.005 # 每腿模拟滑点 (%)
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Binaries
|
||||||
|
exchange-monitor
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Env files with secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
trade_stats.txt
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_*.go
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
TelegramBotToken string
|
||||||
|
TelegramChatID string
|
||||||
|
AlertCooldownSec int // seconds between alerts for same coin
|
||||||
|
ArbThreshold float64 // minimum net profit % to trigger alert
|
||||||
|
ScanIntervalMs int // how often scanner runs (milliseconds)
|
||||||
|
|
||||||
|
// Automated trading
|
||||||
|
TradeEnabled bool
|
||||||
|
TradeThreshold float64 // minimum profit % to execute trade (>0.15%)
|
||||||
|
TradeAmountUSD float64 // amount per trade in USDT
|
||||||
|
TradeCooldownMs int // ms between trades of same coin
|
||||||
|
|
||||||
|
// Test mode (no real API keys needed)
|
||||||
|
TestMode bool
|
||||||
|
MockSlippagePct float64 // simulated slippage per order (e.g. 0.01 = 0.01%)
|
||||||
|
|
||||||
|
// Bitget API
|
||||||
|
BitgetAPIKey string
|
||||||
|
BitgetAPISecret string
|
||||||
|
BitgetPassphrase string
|
||||||
|
|
||||||
|
// HyperLiquid API
|
||||||
|
HLPrivateKey string // ed25519 private key hex
|
||||||
|
HLAddress string // wallet address
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
getEnv := func(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
getFloat := func(key string, def float64) float64 {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
getBool := func(key string, def bool) bool {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v == "1" || v == "true" || v == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
||||||
|
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""),
|
||||||
|
AlertCooldownSec: 300,
|
||||||
|
ArbThreshold: 0.03,
|
||||||
|
ScanIntervalMs: 500,
|
||||||
|
|
||||||
|
TradeEnabled: getBool("TRADE_ENABLED", false),
|
||||||
|
TradeThreshold: getFloat("TRADE_THRESHOLD", 0.15),
|
||||||
|
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", 10),
|
||||||
|
TradeCooldownMs: 30000,
|
||||||
|
|
||||||
|
TestMode: getBool("TEST_MODE", false),
|
||||||
|
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", 0.005),
|
||||||
|
|
||||||
|
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
||||||
|
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
||||||
|
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
|
||||||
|
|
||||||
|
HLPrivateKey: getEnv("HL_PRIVATE_KEY", ""),
|
||||||
|
HLAddress: getEnv("HL_ADDRESS", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AevoWS connects to Aevo WebSocket for ticker data.
|
||||||
|
type AevoWS struct {
|
||||||
|
Conn *PriceConnector
|
||||||
|
Tracked []TrackedSymbol // coin + instrument name pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackedSymbol struct {
|
||||||
|
Coin string // "BTC"
|
||||||
|
InstrumentID string // "BTC-PERP"
|
||||||
|
}
|
||||||
|
|
||||||
|
type aevoTickerMsg struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aevoTickerData struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Tickers []aevoInstrument `json:"tickers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aevoInstrument struct {
|
||||||
|
InstrumentName string `json:"instrument_name"`
|
||||||
|
Mark *aevoPriceObj `json:"mark,omitempty"`
|
||||||
|
LastPrice string `json:"last_price,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aevoPriceObj struct {
|
||||||
|
Price string `json:"price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAevoWS(tracked []TrackedSymbol) *AevoWS {
|
||||||
|
ae := &AevoWS{
|
||||||
|
Tracked: tracked,
|
||||||
|
Conn: NewPriceConnector("wss://ws.aevo.xyz", "Aevo", 120*time.Second, 30*time.Second),
|
||||||
|
}
|
||||||
|
ae.Conn.PingInterval = 45 * time.Second
|
||||||
|
return ae
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to Aevo WS and streams ticker data.
|
||||||
|
func (a *AevoWS) Run(updateFn func(coin string, price float64)) error {
|
||||||
|
a.Conn.OnConnect = func() {
|
||||||
|
log.Printf("[Aevo WS] Connected, subscribing to %d tickers", len(a.Tracked))
|
||||||
|
|
||||||
|
for _, t := range a.Tracked {
|
||||||
|
sub := map[string]interface{}{
|
||||||
|
"op": "subscribe",
|
||||||
|
"data": []string{"ticker:" + t.Coin},
|
||||||
|
}
|
||||||
|
if err := a.Conn.SendJSON(sub); err != nil {
|
||||||
|
log.Printf("[Aevo WS] Subscribe %s error: %v", t.InstrumentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Conn.OnMessage = func(msg []byte) {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
if errMsg, hasErr := raw["error"]; hasErr {
|
||||||
|
var errStr string
|
||||||
|
json.Unmarshal(errMsg, &errStr)
|
||||||
|
if errStr != "" {
|
||||||
|
// Log once, skip errors
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ticker data
|
||||||
|
op, hasOp := raw["op"]
|
||||||
|
if !hasOp {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var opStr string
|
||||||
|
if err := json.Unmarshal(op, &opStr); err != nil || opStr != "ticker" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRaw, hasData := raw["data"]
|
||||||
|
if !hasData {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data aevoTickerData
|
||||||
|
if err := json.Unmarshal(dataRaw, &data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ticker := range data.Tickers {
|
||||||
|
// Find the coin for this instrument
|
||||||
|
coin := ""
|
||||||
|
for _, t := range a.Tracked {
|
||||||
|
if t.InstrumentID == ticker.InstrumentName {
|
||||||
|
coin = t.Coin
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if coin == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try mark price first, then last_price
|
||||||
|
var price float64
|
||||||
|
if ticker.Mark != nil && ticker.Mark.Price != "" {
|
||||||
|
price = parseFloat(ticker.Mark.Price)
|
||||||
|
} else if ticker.LastPrice != "" {
|
||||||
|
price = parseFloat(ticker.LastPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if price > 0 {
|
||||||
|
updateFn(coin, price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.Conn.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BinanceWS struct {
|
||||||
|
Tracked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBinanceWS(tracked []string) *BinanceWS {
|
||||||
|
return &BinanceWS{Tracked: tracked}
|
||||||
|
}
|
||||||
|
// Run connects to Binance WS and streams bookTicker data.
|
||||||
|
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
streams := ""
|
||||||
|
for i, sym := range b.Tracked {
|
||||||
|
if i > 0 {
|
||||||
|
streams += "/"
|
||||||
|
}
|
||||||
|
streams += fmt.Sprintf("%s@bookTicker", strings.ToLower(sym))
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("wss://fstream.binance.com/stream?streams=%s", streams)
|
||||||
|
|
||||||
|
conn := NewPriceConnector(url, "Binance", 120*time.Second, 30*time.Second)
|
||||||
|
conn.PingInterval = 45 * time.Second
|
||||||
|
|
||||||
|
conn.OnConnect = func() {
|
||||||
|
log.Printf("[Binance WS] Connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.OnMessage = func(msg []byte) {
|
||||||
|
// Combined stream: {"stream":"...","data":{...}}
|
||||||
|
// Navigate through "data" using map to avoid field name conflicts
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataRaw, ok := raw["data"]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data object as flat map to extract fields by exact name
|
||||||
|
var dataMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(dataRaw, &dataMap); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol, _ := dataMap["s"].(string)
|
||||||
|
bidStr, _ := dataMap["b"].(string)
|
||||||
|
askStr, _ := dataMap["a"].(string)
|
||||||
|
if symbol == "" || bidStr == "" || askStr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bid, err1 := strconv.ParseFloat(bidStr, 64)
|
||||||
|
ask, err2 := strconv.ParseFloat(askStr, 64)
|
||||||
|
if err1 != nil || err2 != nil || bid <= 0 || ask <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract coin name (e.g., "BTCUSDT" -> "BTC")
|
||||||
|
coin := symbolToCoin(symbol, "USDT")
|
||||||
|
if coin == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mid := (bid + ask) / 2.0
|
||||||
|
updateFn(coin, mid, bid, ask)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BitgetWS connects to Bitget WebSocket for ticker channel.
|
||||||
|
type BitgetWS struct {
|
||||||
|
Conn *PriceConnector
|
||||||
|
Tracked []string // Bitget symbols like BTCUSDT
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitgetSubscribeMsg struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Args []bitgetChannel `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitgetChannel struct {
|
||||||
|
InstType string `json:"instType"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
InstID string `json:"instId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitgetTickerMsg struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Arg bitgetChannel `json:"arg"`
|
||||||
|
Data []bitgetTickerData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitgetTickerData struct {
|
||||||
|
LastPr string `json:"lastPr"`
|
||||||
|
BidPr string `json:"bidPr"`
|
||||||
|
AskPr string `json:"askPr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBitgetWS(tracked []string) *BitgetWS {
|
||||||
|
return &BitgetWS{
|
||||||
|
Tracked: tracked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to Bitget WS and streams ticker data.
|
||||||
|
func (b *BitgetWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
url := "wss://ws.bitget.com/v2/ws/public"
|
||||||
|
|
||||||
|
b.Conn = NewPriceConnector(url, "Bitget", 120*time.Second, 30*time.Second)
|
||||||
|
b.Conn.PingInterval = 25 * time.Second // Bitget requires ping within 30s
|
||||||
|
|
||||||
|
b.Conn.OnConnect = func() {
|
||||||
|
log.Printf("[Bitget WS] Connected, subscribing")
|
||||||
|
|
||||||
|
args := make([]map[string]string, 0, len(b.Tracked))
|
||||||
|
for _, sym := range b.Tracked {
|
||||||
|
args = append(args, map[string]string{
|
||||||
|
"instType": "USDT-FUTURES",
|
||||||
|
"channel": "ticker",
|
||||||
|
"instId": sym,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sub := map[string]interface{}{
|
||||||
|
"op": "subscribe",
|
||||||
|
"args": args,
|
||||||
|
}
|
||||||
|
if err := b.Conn.SendJSON(sub); err != nil {
|
||||||
|
log.Printf("[Bitget WS] Subscribe error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Conn.OnMessage = func(msg []byte) {
|
||||||
|
var ticker bitgetTickerMsg
|
||||||
|
if err := json.Unmarshal(msg, &ticker); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ticker.Data) == 0 || ticker.Data[0].LastPr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert BTCUSDT -> BTC
|
||||||
|
coin := symbolToCoin(ticker.Arg.InstID, "USDT")
|
||||||
|
if coin == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
price := parseFloat(ticker.Data[0].LastPr)
|
||||||
|
if price > 0 {
|
||||||
|
bid := parseFloat(ticker.Data[0].BidPr)
|
||||||
|
ask := parseFloat(ticker.Data[0].AskPr)
|
||||||
|
updateFn(coin, price, bid, ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Conn.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BitgetTrade handles order placement on Bitget.
|
||||||
|
type BitgetTrade struct {
|
||||||
|
APIKey string
|
||||||
|
APISecret string
|
||||||
|
Passphrase string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBitgetTrade(apiKey, apiSecret, passphrase string) *BitgetTrade {
|
||||||
|
return &BitgetTrade{
|
||||||
|
APIKey: apiKey,
|
||||||
|
APISecret: apiSecret,
|
||||||
|
Passphrase: passphrase,
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceMarketOrder places a market order on Bitget.
|
||||||
|
// side: "buy" or "sell"
|
||||||
|
// symbol: "BTCUSDT" (we use UMCBL perpetual)
|
||||||
|
// size: contract size in coin units (e.g. 0.001 for BTC)
|
||||||
|
func (b *BitgetTrade) PlaceMarketOrder(side, symbol, size string) (string, error) {
|
||||||
|
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
|
method := "POST"
|
||||||
|
requestPath := "/api/v2/mix/order/place"
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"symbol": symbol + "_UMCBL",
|
||||||
|
"marginCoin": "USDT",
|
||||||
|
"side": side,
|
||||||
|
"orderType": "market",
|
||||||
|
"size": size,
|
||||||
|
"timeInForce": "IOC", // immediate-or-cancel for market orders
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
sign := b.sign(method, requestPath, ts, string(bodyJSON))
|
||||||
|
|
||||||
|
url := "https://api.bitget.com" + requestPath
|
||||||
|
req, err := http.NewRequest(method, url, strings.NewReader(string(bodyJSON)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
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 "", fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
OrderID string `json:"orderId"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %s", string(respBody))
|
||||||
|
}
|
||||||
|
if result.Code != "00000" {
|
||||||
|
return "", fmt.Errorf("bitget error: %s - %s", result.Code, result.Msg)
|
||||||
|
}
|
||||||
|
return result.Data.OrderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BitgetTrade) sign(method, requestPath, timestamp, body string) string {
|
||||||
|
raw := timestamp + method + requestPath + body
|
||||||
|
mac := hmac.New(sha256.New, []byte(b.APISecret))
|
||||||
|
mac.Write([]byte(raw))
|
||||||
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBitgetSize calculates the contract size for a given USD amount.
|
||||||
|
// Returns size as a decimal string complying with Bitget's USDT-FUTURES precision.
|
||||||
|
// Enforces the exchange's minimum: minTradeNum contracts AND $5 min notional.
|
||||||
|
func GetBitgetSize(symbol string, amountUSD, price float64) string {
|
||||||
|
if amountUSD < 5 {
|
||||||
|
amountUSD = 5 // Bitget minimum notional
|
||||||
|
}
|
||||||
|
sz := amountUSD / price // raw coin count
|
||||||
|
|
||||||
|
switch symbol {
|
||||||
|
case "DOGEUSDT":
|
||||||
|
if sz < 1 {
|
||||||
|
sz = 1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1
|
||||||
|
case "LINKUSDT":
|
||||||
|
if sz < 1 {
|
||||||
|
sz = 1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f", sz) // minTradeNum=1, sizeMultiplier=1
|
||||||
|
case "ONDOUSDT":
|
||||||
|
if sz < 0.1 {
|
||||||
|
sz = 0.1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
|
||||||
|
case "OPUSDT":
|
||||||
|
if sz < 0.1 {
|
||||||
|
sz = 0.1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
|
||||||
|
case "WIFUSDT":
|
||||||
|
if sz < 0.1 {
|
||||||
|
sz = 0.1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f", sz) // minTradeNum=0.1, sizeMultiplier=0.1
|
||||||
|
case "ARBUSDT":
|
||||||
|
if sz < 0.01 {
|
||||||
|
sz = 0.01
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f", sz) // minTradeNum=0.01, sizeMultiplier=0.01
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.4f", sz)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PriceConnector is a reusable WebSocket reconnector with backoff and keepalive.
|
||||||
|
type PriceConnector struct {
|
||||||
|
URL string
|
||||||
|
Name string
|
||||||
|
ReadTimeout time.Duration
|
||||||
|
ReconnectBase time.Duration
|
||||||
|
PingInterval time.Duration // 0 = no client-side pings
|
||||||
|
|
||||||
|
OnConnect func()
|
||||||
|
OnMessage func([]byte)
|
||||||
|
OnError func(error)
|
||||||
|
|
||||||
|
conn *websocket.Conn
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPriceConnector(url, name string, readTimeout, reconnectBase time.Duration) *PriceConnector {
|
||||||
|
return &PriceConnector{
|
||||||
|
URL: url,
|
||||||
|
Name: name,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
ReconnectBase: reconnectBase,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PriceConnector) Run() error {
|
||||||
|
backoff := time.Second
|
||||||
|
attempt := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pc.done:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s WS] Connecting... (attempt %d)", pc.Name, attempt+1)
|
||||||
|
|
||||||
|
c, _, err := websocket.DefaultDialer.Dial(pc.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s WS] Dial error: %v (retry in %v)", pc.Name, err, backoff)
|
||||||
|
if pc.OnError != nil {
|
||||||
|
pc.OnError(err)
|
||||||
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > 30*time.Second {
|
||||||
|
backoff = 30 * time.Second
|
||||||
|
}
|
||||||
|
attempt++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff = time.Second
|
||||||
|
attempt = 0
|
||||||
|
pc.conn = c
|
||||||
|
|
||||||
|
// ── Ping/Pong keepalive ──
|
||||||
|
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
|
||||||
|
|
||||||
|
// Respond to server pings with pongs (extend deadline)
|
||||||
|
c.SetPongHandler(func(appData string) error {
|
||||||
|
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle server-initiated PING frames (used by Bitget etc.)
|
||||||
|
// Extend read deadline so the connection doesn't timeout
|
||||||
|
c.SetPingHandler(func(appData string) error {
|
||||||
|
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Client-side ping sender
|
||||||
|
pingStop := make(chan struct{})
|
||||||
|
if pc.PingInterval > 0 {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(pc.PingInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-pingStop:
|
||||||
|
return
|
||||||
|
case <-pc.done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc.OnConnect != nil {
|
||||||
|
pc.OnConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read loop
|
||||||
|
readLoop:
|
||||||
|
for {
|
||||||
|
_, msg, err := c.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s WS] Read error: %v", pc.Name, err)
|
||||||
|
break readLoop
|
||||||
|
}
|
||||||
|
c.SetReadDeadline(time.Now().Add(pc.ReadTimeout))
|
||||||
|
|
||||||
|
if pc.OnMessage != nil {
|
||||||
|
pc.OnMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(pingStop)
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pc *PriceConnector) Stop() {
|
||||||
|
close(pc.done)
|
||||||
|
if pc.conn != nil {
|
||||||
|
pc.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that's closed when the connector is stopped.
|
||||||
|
func (pc *PriceConnector) Done() <-chan struct{} {
|
||||||
|
return pc.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendJSON sends a JSON message over the WebSocket.
|
||||||
|
func (pc *PriceConnector) SendJSON(v interface{}) error {
|
||||||
|
if pc.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pc.conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: parse float from string
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
var f float64
|
||||||
|
if _, err := fmt.Sscanf(s, "%f", &f); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: convert "BTCUSDT" with suffix "USDT" to "BTC"
|
||||||
|
func symbolToCoin(symbol, suffix string) string {
|
||||||
|
if len(symbol) <= len(suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if symbol[len(symbol)-len(suffix):] == suffix {
|
||||||
|
return symbol[:len(symbol)-len(suffix)]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DydxWS connects to dYdX v4 WebSocket for market data (oracle prices).
|
||||||
|
type DydxWS struct {
|
||||||
|
Conn *PriceConnector
|
||||||
|
Tracked []string // coin names like ["BTC", "ETH", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
type dydxSubscribeMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dydxMarketMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Contents json.RawMessage `json:"contents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dydxMarketContents struct {
|
||||||
|
OraclePrice string `json:"oraclePrice"`
|
||||||
|
MarkPrice string `json:"markPrice"`
|
||||||
|
NextFundingRate string `json:"nextFundingRate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDydxWS(tracked []string) *DydxWS {
|
||||||
|
return &DydxWS{Tracked: tracked}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to dYdX v4 WS and streams oracle/market prices.
|
||||||
|
func (d *DydxWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
url := "wss://indexer.dydx.trade/v4/ws"
|
||||||
|
|
||||||
|
d.Conn = NewPriceConnector(url, "dYdX", 120*time.Second, 30*time.Second)
|
||||||
|
// dYdX v4 requires JSON {"type":"ping"} heartbeat
|
||||||
|
|
||||||
|
d.Conn.OnConnect = func() {
|
||||||
|
log.Printf("[dYdX WS] Connected, subscribing")
|
||||||
|
|
||||||
|
// Subscribe to all markets (gets all coins in one stream)
|
||||||
|
sub := dydxSubscribeMsg{
|
||||||
|
Type: "subscribe",
|
||||||
|
Channel: "v4_markets",
|
||||||
|
}
|
||||||
|
if err := d.Conn.SendJSON(sub); err != nil {
|
||||||
|
log.Printf("[dYdX WS] Subscribe error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dYdX requires JSON {"type":"ping"} every ~30s
|
||||||
|
go func() {
|
||||||
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
// Send first ping after 10s (let subscription settle)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if err := d.Conn.SendJSON(map[string]string{"type": "ping"}); err != nil {
|
||||||
|
log.Printf("[dYdX WS] Heartbeat send error: %v", err)
|
||||||
|
}
|
||||||
|
case <-d.Conn.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Conn.OnMessage = func(msg []byte) {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check type
|
||||||
|
var msgType string
|
||||||
|
if err := json.Unmarshal(raw["type"], &msgType); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgType != "channel_data" {
|
||||||
|
// Handle initial subscription response with all markets
|
||||||
|
if msgType == "subscribed" {
|
||||||
|
var contents struct {
|
||||||
|
Markets map[string]struct {
|
||||||
|
OraclePrice string `json:"oraclePrice"`
|
||||||
|
} `json:"markets"`
|
||||||
|
}
|
||||||
|
contentsRaw, ok := raw["contents"]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(contentsRaw, &contents); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for marketID, market := range contents.Markets {
|
||||||
|
if market.OraclePrice == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coin := dydxSymbolToCoin(marketID)
|
||||||
|
if coin == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isTracked(d.Tracked, coin) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price := parseFloat(market.OraclePrice)
|
||||||
|
if price > 0 {
|
||||||
|
updateFn(coin, price, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live updates - type "channel_data" with oraclePrices
|
||||||
|
contentsRaw, ok := raw["contents"]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var contents struct {
|
||||||
|
OraclePrices map[string]struct {
|
||||||
|
OraclePrice string `json:"oraclePrice"`
|
||||||
|
} `json:"oraclePrices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(contentsRaw, &contents); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for marketID, data := range contents.OraclePrices {
|
||||||
|
if data.OraclePrice == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coin := dydxSymbolToCoin(marketID)
|
||||||
|
if coin == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isTracked(d.Tracked, coin) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price := parseFloat(data.OraclePrice)
|
||||||
|
if price > 0 {
|
||||||
|
updateFn(coin, price, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Conn.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dydxSymbolToCoin converts "BTC-USD" -> "BTC", "ETH-USD" -> "ETH"
|
||||||
|
func dydxSymbolToCoin(symbol string) string {
|
||||||
|
if len(symbol) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Remove "-USD" suffix
|
||||||
|
if len(symbol) > 4 && symbol[len(symbol)-4:] == "-USD" {
|
||||||
|
return symbol[:len(symbol)-4]
|
||||||
|
}
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTracked(list []string, coin string) bool {
|
||||||
|
for _, t := range list {
|
||||||
|
if t == coin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import "github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
// These are needed for compilation of the exchange package.
|
||||||
|
// PriceConnector is defined in connector.go.
|
||||||
|
var _ = websocket.ErrCloseSent // keep gorilla/websocket import
|
||||||
|
|
||||||
|
// CalcNetProfit calculates net profit % for a complete round trip (entry + exit) between two exchanges.
|
||||||
|
// buyPrice: price on the buy exchange
|
||||||
|
// sellPrice: price on the sell exchange
|
||||||
|
// buyFee: fee rate on buy exchange (e.g. 0.03 for 0.03%)
|
||||||
|
// sellFee: fee rate on sell exchange
|
||||||
|
// buyFee2: buy fee on the other exchange
|
||||||
|
// sellFee2: sell fee on the other exchange
|
||||||
|
// Returns net profit in percentage.
|
||||||
|
func CalcNetProfit(price1, price2, fee1Buy, fee1Sell, fee2Buy, fee2Sell float64) float64 {
|
||||||
|
// price1 = Bitget, price2 = HyperLiquid
|
||||||
|
// Try: buy cheap (min), sell expensive (max)
|
||||||
|
buyPrice := price1
|
||||||
|
sellPrice := price2
|
||||||
|
buyFee := fee1Buy
|
||||||
|
sellFee := fee2Sell
|
||||||
|
|
||||||
|
if price2 < price1 {
|
||||||
|
buyPrice = price2
|
||||||
|
sellPrice = price1
|
||||||
|
buyFee = fee2Buy
|
||||||
|
sellFee = fee1Sell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee)
|
||||||
|
if buyPrice <= 0 || sellPrice <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cost := buyPrice * (1 + buyFee/100)
|
||||||
|
revenue := sellPrice * (1 - sellFee/100)
|
||||||
|
|
||||||
|
// Exit: sell long (pay sellFee), buy back short (pay buyFee)
|
||||||
|
// Total fees = 2 * (buyFee + sellFee), first round already in formula above
|
||||||
|
return (revenue/cost-1)*100 - (buyFee + sellFee)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HyperLiquidWS struct {
|
||||||
|
Tracked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type hlAllMidsMsg struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hlMidsData struct {
|
||||||
|
Mids map[string]string `json:"mids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHyperLiquidWS(tracked []string) *HyperLiquidWS {
|
||||||
|
return &HyperLiquidWS{Tracked: tracked}
|
||||||
|
}
|
||||||
|
// Run connects to HyperLiquid WS and streams mid prices.
|
||||||
|
func (h *HyperLiquidWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
conn := NewPriceConnector("wss://api.hyperliquid.xyz/ws", "HyperLiquid", 120*time.Second, 30*time.Second)
|
||||||
|
conn.PingInterval = 45 * time.Second
|
||||||
|
|
||||||
|
conn.OnConnect = func() {
|
||||||
|
log.Printf("[HL WS] Connected")
|
||||||
|
sub := map[string]interface{}{
|
||||||
|
"method": "subscribe",
|
||||||
|
"subscription": map[string]string{
|
||||||
|
"type": "allMids",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := conn.SendJSON(sub); err != nil {
|
||||||
|
log.Printf("[HL WS] Subscribe error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.OnMessage = func(msg []byte) {
|
||||||
|
var raw hlAllMidsMsg
|
||||||
|
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw.Channel != "allMids" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var data hlMidsData
|
||||||
|
if err := json.Unmarshal(raw.Data, &data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for coin, priceStr := range data.Mids {
|
||||||
|
price, err := strconv.ParseFloat(priceStr, 64)
|
||||||
|
if err != nil || price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updateFn(coin, price, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha512"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HLOrderAction represents a HyperLiquid order action.
|
||||||
|
type HLOrderAction struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Order HLOrder `json:"order"`
|
||||||
|
Grouping string `json:"grouping"`
|
||||||
|
BrokerCode int `json:"brokerCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HLOrder struct {
|
||||||
|
Coin string `json:"coin"`
|
||||||
|
IsBuy bool `json:"isBuy"`
|
||||||
|
Sz string `json:"sz"`
|
||||||
|
LimitPx string `json:"limitPx"`
|
||||||
|
OrderType string `json:"orderType"`
|
||||||
|
ReduceOnly bool `json:"reduceOnly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLSignedAction wraps the action with signature.
|
||||||
|
type HLSignedAction struct {
|
||||||
|
Action HLOrderAction `json:"action"`
|
||||||
|
Nonce int64 `json:"nonce"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
VaultAddress string `json:"vaultAddress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HLOrderResponse struct {
|
||||||
|
Response *json.RawMessage `json:"response"`
|
||||||
|
Data *json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HyperLiquidTrade handles order placement on HyperLiquid.
|
||||||
|
type HyperLiquidTrade struct {
|
||||||
|
PrivateKey ed25519.PrivateKey
|
||||||
|
Address string
|
||||||
|
client *http.Client
|
||||||
|
lastNonce int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHyperLiquidTrade(privateKeyHex, address string) (*HyperLiquidTrade, error) {
|
||||||
|
if privateKeyHex == "" {
|
||||||
|
return &HyperLiquidTrade{client: &http.Client{Timeout: 10 * time.Second}}, nil
|
||||||
|
}
|
||||||
|
keyBytes, err := hex.DecodeString(privateKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode private key: %w", err)
|
||||||
|
}
|
||||||
|
if len(keyBytes) != ed25519.PrivateKeySize {
|
||||||
|
return nil, fmt.Errorf("invalid private key length: %d (expected %d)", len(keyBytes), ed25519.PrivateKeySize)
|
||||||
|
}
|
||||||
|
return &HyperLiquidTrade{
|
||||||
|
PrivateKey: ed25519.PrivateKey(keyBytes),
|
||||||
|
Address: address,
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HyperLiquidTrade) IsConfigured() bool {
|
||||||
|
return h.PrivateKey != nil && h.Address != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceMarketOrder places a market order on HyperLiquid.
|
||||||
|
// coin: "BTC", side: "buy" or "sell", sz: order size in coin units (e.g. "0.001")
|
||||||
|
func (h *HyperLiquidTrade) PlaceMarketOrder(coin, side, sz string) (string, error) {
|
||||||
|
if !h.IsConfigured() {
|
||||||
|
return "", fmt.Errorf("HyperLiquid not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
isBuy := side == "buy"
|
||||||
|
action := HLOrderAction{
|
||||||
|
Type: "order",
|
||||||
|
Order: HLOrder{
|
||||||
|
Coin: coin,
|
||||||
|
IsBuy: isBuy,
|
||||||
|
Sz: sz,
|
||||||
|
LimitPx: "1000000", // high limit price for market orders
|
||||||
|
OrderType: "IOC",
|
||||||
|
ReduceOnly: false,
|
||||||
|
},
|
||||||
|
Grouping: "na",
|
||||||
|
BrokerCode: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate nonce
|
||||||
|
h.lastNonce++
|
||||||
|
nonce := time.Now().UnixMilli()*1_000_000 + h.lastNonce%1_000_000
|
||||||
|
|
||||||
|
// Sign the action
|
||||||
|
sig, err := h.signAction(action, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sign: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := HLSignedAction{
|
||||||
|
Action: action,
|
||||||
|
Nonce: nonce,
|
||||||
|
Signature: sig,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyJSON, _ := json.Marshal(signed)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://api.hyperliquid.xyz/exchange", strings.NewReader(string(bodyJSON)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var response json.RawMessage
|
||||||
|
if err := json.Unmarshal(respBody, &response); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %s", string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HL returns response as array, e.g. [{"response": {"type": "order", "data": ...}}]
|
||||||
|
var results []map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(respBody, &results); err != nil {
|
||||||
|
// Maybe returns single object
|
||||||
|
return string(respBody), nil
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return "", fmt.Errorf("empty response: %s", string(respBody))
|
||||||
|
}
|
||||||
|
respJSON, _ := json.Marshal(results[0])
|
||||||
|
return string(respJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signAction generates an Ed25519 signature for a HyperLiquid action.
|
||||||
|
// The signing hash is SHA512(action_json + nonce).
|
||||||
|
func (h *HyperLiquidTrade) signAction(action HLOrderAction, nonce int64) (string, error) {
|
||||||
|
actionJSON, err := json.Marshal(action)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HL signs: hash = sha512(action_json + nonce)
|
||||||
|
nonceBig := big.NewInt(nonce)
|
||||||
|
msg := string(actionJSON) + nonceBig.String()
|
||||||
|
hash := sha512.Sum512([]byte(msg))
|
||||||
|
|
||||||
|
sig := ed25519.Sign(h.PrivateKey, hash[:])
|
||||||
|
return "0x" + hex.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHLSize calculates size for a given USD amount on HyperLiquid.
|
||||||
|
// Uses szDecimals precision from HL's contract universe.
|
||||||
|
// Returns size as a decimal string complying with HL precision.
|
||||||
|
func GetHLSize(coin string, amountUSD, price float64) string {
|
||||||
|
sz := amountUSD / price // raw coin count
|
||||||
|
switch coin {
|
||||||
|
case "DOGE":
|
||||||
|
return fmt.Sprintf("%.0f", sz) // szDecimals=0
|
||||||
|
case "LINK":
|
||||||
|
return fmt.Sprintf("%.1f", sz) // szDecimals=1
|
||||||
|
case "ONDO":
|
||||||
|
return fmt.Sprintf("%.0f", sz) // szDecimals=0
|
||||||
|
case "OP":
|
||||||
|
return fmt.Sprintf("%.1f", sz) // szDecimals=1
|
||||||
|
case "WIF":
|
||||||
|
return fmt.Sprintf("%.0f", sz) // szDecimals=0
|
||||||
|
case "ARB":
|
||||||
|
return fmt.Sprintf("%.1f", sz) // szDecimals=1
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.4f", sz)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// WithPing returns a copy of pc with PingInterval set.
|
||||||
|
// Use this in each exchange's Run() before calling b.Conn.Run().
|
||||||
|
func WithPing(pc *PriceConnector, interval time.Duration) *PriceConnector {
|
||||||
|
pc.PingInterval = interval
|
||||||
|
return pc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard ping intervals per exchange
|
||||||
|
const (
|
||||||
|
PingBitget = 25 * time.Second // Bitget requires ping within 30s
|
||||||
|
PingNormal = 45 * time.Second // General keepalive
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module exchange-monitor
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"exchange-monitor/exchange"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||||
|
|
||||||
|
// Set up multi-writer: stdout + log file
|
||||||
|
logPath := os.ExpandEnv("$HOME/Project/exchange-monitor-go/exchange-monitor.log")
|
||||||
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err == nil {
|
||||||
|
multi := io.MultiWriter(os.Stdout, logFile)
|
||||||
|
log.SetOutput(multi)
|
||||||
|
} else {
|
||||||
|
log.SetOutput(os.Stdout)
|
||||||
|
}
|
||||||
|
log.Println("[Exchange Monitor] Starting...")
|
||||||
|
|
||||||
|
loadDotEnv()
|
||||||
|
cfg := LoadConfig()
|
||||||
|
|
||||||
|
store := NewPriceStore()
|
||||||
|
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
|
||||||
|
// Initialize trader
|
||||||
|
trader := NewTrader(cfg)
|
||||||
|
if trader.IsConfigured() {
|
||||||
|
modeLabel := trader.ModeLabel()
|
||||||
|
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
|
||||||
|
modeLabel, cfg.TradeThreshold, cfg.TradeAmountUSD)
|
||||||
|
if cfg.TestMode {
|
||||||
|
log.Printf("[Trader] Using mock orders with %.3f%% slippage per leg", cfg.MockSlippagePct)
|
||||||
|
}
|
||||||
|
log.Printf("[Trader] Bitget+HL: BG->HL / HL->BG only")
|
||||||
|
} else {
|
||||||
|
log.Printf("[Trader] Automated trading DISABLED (set TRADE_ENABLED=1 or TEST_MODE=true in .env)")
|
||||||
|
}
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
|
||||||
|
|
||||||
|
// Collect symbols
|
||||||
|
var bnSymbols, bgSymbols, hlSymbols []string
|
||||||
|
var aevoSymbols []exchange.TrackedSymbol
|
||||||
|
for _, c := range TrackedCoins {
|
||||||
|
bnSymbols = append(bnSymbols, c.BN)
|
||||||
|
bgSymbols = append(bgSymbols, c.BG)
|
||||||
|
hlSymbols = append(hlSymbols, c.HL)
|
||||||
|
aevoSymbols = append(aevoSymbols, exchange.TrackedSymbol{
|
||||||
|
Coin: c.Name,
|
||||||
|
InstrumentID: c.Name + "-PERP",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all exchange WS connections
|
||||||
|
startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
err := runner(func(coin string, price, bid, ask float64) {
|
||||||
|
store.SetWithSpread(coin, name, price, bid, ask)
|
||||||
|
})
|
||||||
|
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
|
||||||
|
select {
|
||||||
|
case <-sigCh:
|
||||||
|
return
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
|
||||||
|
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
|
||||||
|
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run)
|
||||||
|
startExchange("dYdX", exchange.NewDydxWS(hlSymbols).Run)
|
||||||
|
|
||||||
|
log.Println("[Monitor] Waiting for initial data...")
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
// Main loop
|
||||||
|
lastHour := -1
|
||||||
|
scannerTick := time.NewTicker(time.Duration(cfg.ScanIntervalMs) * time.Millisecond)
|
||||||
|
statusTick := time.NewTicker(30 * time.Second)
|
||||||
|
|
||||||
|
log.Printf("[Monitor] Scanner running every %dms", cfg.ScanIntervalMs)
|
||||||
|
|
||||||
|
runLoop := true
|
||||||
|
for runLoop {
|
||||||
|
select {
|
||||||
|
case sig := <-sigCh:
|
||||||
|
if sig == syscall.SIGUSR1 {
|
||||||
|
// Dump stats on request
|
||||||
|
converged, diverged, flat, total := trader.GetClosedStats()
|
||||||
|
stats := fmt.Sprintf("=== 收敛统计 === %s\n", time.Now().Format("2006-01-02 15:04"))
|
||||||
|
stats += fmt.Sprintf(" 总交易数: %d\n", total)
|
||||||
|
stats += fmt.Sprintf(" 价差收敛: %d\n", converged)
|
||||||
|
stats += fmt.Sprintf(" 价差持平: %d\n", flat)
|
||||||
|
stats += fmt.Sprintf(" 价差发散: %d\n", diverged)
|
||||||
|
if total > 0 {
|
||||||
|
stats += fmt.Sprintf(" 收敛率: %.1f%%\n", float64(converged)/float64(total)*100)
|
||||||
|
}
|
||||||
|
log.Printf("[Monitor] SIGUSR1 received — wrote stats to trade_stats.txt")
|
||||||
|
statsPath := os.ExpandEnv("$HOME/Project/exchange-monitor-go/trade_stats.txt")
|
||||||
|
os.WriteFile(statsPath, []byte(stats), 0644)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println("[Monitor] Shutting down...")
|
||||||
|
runLoop = false
|
||||||
|
|
||||||
|
case <-statusTick.C:
|
||||||
|
snap := store.GetAll()
|
||||||
|
count := 0
|
||||||
|
for _, exMap := range snap {
|
||||||
|
count += len(exMap)
|
||||||
|
}
|
||||||
|
log.Printf("[Status] %d prices / %d coins connected", count, len(snap))
|
||||||
|
|
||||||
|
// Show open positions
|
||||||
|
if positions := trader.GetOpenPositions(); len(positions) > 0 {
|
||||||
|
for _, pos := range positions {
|
||||||
|
log.Printf(" [Position] %s %s open %d scales $%.0f since %s",
|
||||||
|
pos.Coin, pos.Direction, pos.ScaleLevels, pos.AmountUSD,
|
||||||
|
time.Since(pos.StartedAt).Round(time.Second).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-scannerTick.C:
|
||||||
|
now := time.Now()
|
||||||
|
t0 := now
|
||||||
|
|
||||||
|
// Tick the trader (monitor open positions for exit)
|
||||||
|
trader.Tick(store, notifier)
|
||||||
|
t1 := time.Now()
|
||||||
|
|
||||||
|
// Scan for arbitrage entries using maker fees (limit orders)
|
||||||
|
makerOpps := ScanArbWithFees(store, makerFees)
|
||||||
|
t2 := time.Now()
|
||||||
|
|
||||||
|
for _, opp := range makerOpps {
|
||||||
|
if opp.NetProfit < cfg.ArbThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trader.TryEntry(opp, store, notifier) {
|
||||||
|
log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t3 := time.Now()
|
||||||
|
|
||||||
|
// Profile: warn if any step is slow
|
||||||
|
tickDur := t3.Sub(t0)
|
||||||
|
tickMs := tickDur.Milliseconds()
|
||||||
|
if tickMs > 100 || t1.Sub(t0) > 50 || t2.Sub(t1) > 50 || t3.Sub(t2) > 50 {
|
||||||
|
log.Printf("[Profile] tick=%dms trader=%dms scan=%dms entry=%dms",
|
||||||
|
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hourly trade summary
|
||||||
|
hour := now.Hour()
|
||||||
|
if now.Minute() == 0 && now.Second() < 5 && hour != lastHour {
|
||||||
|
positions := trader.GetOpenPositions()
|
||||||
|
notifier.SendTradeSummary(positions, now.Format("2006-01-02 15:04"))
|
||||||
|
lastHour = hour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[Monitor] Stopped.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDotEnv() {
|
||||||
|
envPath := os.ExpandEnv("$HOME/Project/exchange-monitor-go/.env")
|
||||||
|
if _, err := os.Stat(envPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(envPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := bytes.SplitN(line, []byte("="), 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := string(bytes.TrimSpace(parts[0]))
|
||||||
|
val := string(bytes.TrimSpace(parts[1]))
|
||||||
|
// Strip inline comments
|
||||||
|
if idx := strings.Index(val, "#"); idx >= 0 {
|
||||||
|
val = strings.TrimSpace(val[:idx])
|
||||||
|
}
|
||||||
|
if os.Getenv(key) == "" {
|
||||||
|
os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Morning summary script — triggered by cron at 8 AM
|
||||||
|
|
||||||
|
PROJECT_DIR="/home/jack/Project/exchange-monitor-go"
|
||||||
|
STATS_FILE="$PROJECT_DIR/trade_stats.txt"
|
||||||
|
LOG_FILE="$PROJECT_DIR/morning_summary.txt"
|
||||||
|
|
||||||
|
# Find the PID of the exchange-monitor process
|
||||||
|
PID=$(pgrep -f "exchange-monitor" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
# Send SIGUSR1 to dump stats
|
||||||
|
kill -USR1 "$PID" 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Read stats
|
||||||
|
if [ -f "$STATS_FILE" ]; then
|
||||||
|
cat "$STATS_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "=== 程序状态 ==="
|
||||||
|
ps -p "$PID" -o pid,etime,cmd --no-headers 2>/dev/null || echo "进程已结束"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill the process
|
||||||
|
kill "$PID" 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
kill -0 "$PID" 2>/dev/null && kill -9 "$PID" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "程序已停止 (PID: $PID)"
|
||||||
|
else
|
||||||
|
echo "未找到运行中的 exchange-monitor 进程"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 报告完成 ==="
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Notifier struct {
|
||||||
|
BotToken string
|
||||||
|
ChatID string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(botToken, chatID string) *Notifier {
|
||||||
|
return &Notifier{
|
||||||
|
BotToken: botToken,
|
||||||
|
ChatID: chatID,
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a text message to Telegram.
|
||||||
|
func (n *Notifier) Send(text string) error {
|
||||||
|
if n.BotToken == "" || n.ChatID == "" {
|
||||||
|
log.Printf("[Notifier] Skipped (not configured): %.80s", text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.BotToken)
|
||||||
|
payload := map[string]string{
|
||||||
|
"chat_id": n.ChatID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
resp, err := n.client.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("telegram send error: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("telegram status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Notifier] Sent (%d bytes)", len(text))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAlert sends an arbitrage alert notification.
|
||||||
|
func (n *Notifier) SendAlert(opp *ArbOpportunity) {
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"<b>[套利信号]</b> %s/USDT\n"+
|
||||||
|
" %s %.4f -> %s %.4f\n"+
|
||||||
|
" 净利: <b>%+.4f%%</b>\n",
|
||||||
|
opp.Coin,
|
||||||
|
opp.BuyEx, opp.BuyPrice,
|
||||||
|
opp.SellEx, opp.SellPrice,
|
||||||
|
opp.NetProfit,
|
||||||
|
)
|
||||||
|
if opp.NetProfit > 0.10 {
|
||||||
|
msg += " 高价值机会!\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.Send(msg); err != nil {
|
||||||
|
log.Printf("[Notifier] Alert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTradeSummary sends a summary of open positions at each hour.
|
||||||
|
func (n *Notifier) SendTradeSummary(positions []*ArbPosition, timeStr string) {
|
||||||
|
if n.BotToken == "" || n.ChatID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := fmt.Sprintf("=== 持仓汇总 === %s\n", timeStr)
|
||||||
|
if len(positions) == 0 {
|
||||||
|
lines += " 当前无持仓\n"
|
||||||
|
} else {
|
||||||
|
for i, p := range positions {
|
||||||
|
dur := time.Since(p.StartedAt).Round(time.Second).String()
|
||||||
|
lines += fmt.Sprintf("%d. %s %s %.0f %s\n", i+1, p.Coin, p.Direction, p.AmountUSD, dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := n.Send(lines); err != nil {
|
||||||
|
log.Printf("[Notifier] Hourly error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/jack/Project/exchange-monitor-go
|
||||||
|
# Redirect stderr to stdout so background mode captures everything
|
||||||
|
./exchange-monitor 2>&1
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exchange names
|
||||||
|
const (
|
||||||
|
ExBinance = "Binance"
|
||||||
|
ExHyperLiquid = "HyperLiquid"
|
||||||
|
ExBitget = "Bitget"
|
||||||
|
ExDydx = "dYdX"
|
||||||
|
ExAevo = "Aevo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fee rates (%) — taker fees per exchange
|
||||||
|
var feeRates = map[string]float64{
|
||||||
|
ExBinance: 0.040,
|
||||||
|
ExHyperLiquid: 0.035,
|
||||||
|
ExBitget: 0.040, // standard taker
|
||||||
|
ExDydx: 0.050, // dYdX v4 standard taker
|
||||||
|
ExAevo: 0.050, // Aevo standard taker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maker fee rates (%) — for limit orders
|
||||||
|
var makerFees = map[string]float64{
|
||||||
|
ExBinance: 0.020, // standard maker (USDT pairs)
|
||||||
|
ExHyperLiquid: 0.015,
|
||||||
|
ExBitget: 0.020, // standard maker
|
||||||
|
ExDydx: 0.020,
|
||||||
|
ExAevo: 0.020,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TickerCoins defines all coins we monitor.
|
||||||
|
var TrackedCoins = []TrackedCoin{
|
||||||
|
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE"},
|
||||||
|
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK"},
|
||||||
|
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO"},
|
||||||
|
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP"},
|
||||||
|
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF"},
|
||||||
|
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// netProfit calculates net profit % after fees for a complete round trip (entry + exit).
|
||||||
|
func netProfit(buyPrice, sellPrice, buyFee, sellFee float64) float64 {
|
||||||
|
if buyPrice <= 0 || sellPrice <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Entry: buy at buyPrice (pay buyFee), sell short at sellPrice (pay sellFee)
|
||||||
|
cost := buyPrice * (1 + buyFee/100)
|
||||||
|
revenue := sellPrice * (1 - sellFee/100)
|
||||||
|
// Exit: sell long (pay sellFee), buy back short (pay buyFee)
|
||||||
|
// Total fees = 2 * (buyFee + sellFee), first round already in formula above
|
||||||
|
return (revenue/cost - 1)*100 - (buyFee + sellFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanArbWithFees checks all coins for arbitrage opportunities using a custom fee map.
|
||||||
|
// Pass feeRates for taker fees or makerFees for limit order fees.
|
||||||
|
func ScanArbWithFees(store *PriceStore, fees map[string]float64) []*ArbOpportunity {
|
||||||
|
snapshot := store.GetAll()
|
||||||
|
var results []*ArbOpportunity
|
||||||
|
|
||||||
|
for _, coin := range TrackedCoins {
|
||||||
|
coinStart := time.Now()
|
||||||
|
exMap := snapshot[coin.Name]
|
||||||
|
if exMap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bnP := exMap[ExBinance]
|
||||||
|
hlP := exMap[ExHyperLiquid]
|
||||||
|
bgP := exMap[ExBitget]
|
||||||
|
dyP := exMap[ExDydx]
|
||||||
|
aeP := exMap[ExAevo]
|
||||||
|
|
||||||
|
var pairs []struct {
|
||||||
|
profit float64
|
||||||
|
buyEx string
|
||||||
|
sellEx string
|
||||||
|
buyP float64
|
||||||
|
sellP float64
|
||||||
|
}
|
||||||
|
|
||||||
|
addPair := func(ex1, ex2 string, p1, p2 float64) {
|
||||||
|
if p1 <= 0 || p2 <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pairs = append(pairs,
|
||||||
|
struct {
|
||||||
|
profit float64
|
||||||
|
buyEx string
|
||||||
|
sellEx string
|
||||||
|
buyP float64
|
||||||
|
sellP float64
|
||||||
|
}{netProfit(p1, p2, fees[ex1], fees[ex2]), ex1, ex2, p1, p2},
|
||||||
|
struct {
|
||||||
|
profit float64
|
||||||
|
buyEx string
|
||||||
|
sellEx string
|
||||||
|
buyP float64
|
||||||
|
sellP float64
|
||||||
|
}{netProfit(p2, p1, fees[ex2], fees[ex1]), ex2, ex1, p2, p1},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
addPair(ExBinance, ExHyperLiquid, bnP, hlP)
|
||||||
|
addPair(ExBinance, ExBitget, bnP, bgP)
|
||||||
|
addPair(ExBinance, ExDydx, bnP, dyP)
|
||||||
|
addPair(ExBinance, ExAevo, bnP, aeP)
|
||||||
|
addPair(ExHyperLiquid, ExBitget, hlP, bgP)
|
||||||
|
addPair(ExHyperLiquid, ExDydx, hlP, dyP)
|
||||||
|
addPair(ExHyperLiquid, ExAevo, hlP, aeP)
|
||||||
|
addPair(ExBitget, ExDydx, bgP, dyP)
|
||||||
|
addPair(ExBitget, ExAevo, bgP, aeP)
|
||||||
|
addPair(ExDydx, ExAevo, dyP, aeP)
|
||||||
|
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
best := pairs[0]
|
||||||
|
for _, p := range pairs[1:] {
|
||||||
|
if p.profit > best.profit {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grossBasis := (best.sellP - best.buyP) / best.buyP * 100
|
||||||
|
|
||||||
|
results = append(results, &ArbOpportunity{
|
||||||
|
Coin: coin.Name,
|
||||||
|
Direction: shortName(best.buyEx) + "->" + shortName(best.sellEx),
|
||||||
|
BuyEx: best.buyEx,
|
||||||
|
SellEx: best.sellEx,
|
||||||
|
BuyPrice: best.buyP,
|
||||||
|
SellPrice: best.sellP,
|
||||||
|
NetProfit: best.profit,
|
||||||
|
GrossBasis: grossBasis,
|
||||||
|
})
|
||||||
|
|
||||||
|
coinElapsed := time.Since(coinStart)
|
||||||
|
if coinElapsed > time.Millisecond {
|
||||||
|
log.Printf("[Profile] scan %s took %dµs", coin.Name, coinElapsed.Microseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].NetProfit > results[j].NetProfit
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanArb checks all coins using taker fees.
|
||||||
|
func ScanArb(store *PriceStore) []*ArbOpportunity {
|
||||||
|
return ScanArbWithFees(store, feeRates)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func shortName(exchange string) string {
|
||||||
|
switch exchange {
|
||||||
|
case ExBinance:
|
||||||
|
return "BN"
|
||||||
|
case ExHyperLiquid:
|
||||||
|
return "HL"
|
||||||
|
case ExBitget:
|
||||||
|
return "BG"
|
||||||
|
case ExDydx:
|
||||||
|
return "dYdX"
|
||||||
|
case ExAevo:
|
||||||
|
return "Ae"
|
||||||
|
}
|
||||||
|
return "??"
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
positions map[string]*ArbPosition // coin -> position
|
||||||
|
lastTradeTime map[string]time.Time
|
||||||
|
closedTrades []TradeRecord // history of closed trades
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) *Trader {
|
||||||
|
var bt *exchange.BitgetTrade
|
||||||
|
if cfg.BitgetAPIKey != "" {
|
||||||
|
bt = exchange.NewBitgetTrade(cfg.BitgetAPIKey, cfg.BitgetAPISecret, cfg.BitgetPassphrase)
|
||||||
|
}
|
||||||
|
hl, _ := exchange.NewHyperLiquidTrade(cfg.HLPrivateKey, cfg.HLAddress)
|
||||||
|
|
||||||
|
return &Trader{
|
||||||
|
cfg: cfg,
|
||||||
|
bitget: bt,
|
||||||
|
hyperliquid: hl,
|
||||||
|
positions: make(map[string]*ArbPosition),
|
||||||
|
lastTradeTime: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, snap)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
reProfit := exchange.CalcNetProfit(bgP, hlP,
|
||||||
|
makerFees[ExBitget], makerFees[ExHyperLiquid],
|
||||||
|
makerFees[ExHyperLiquid], makerFees[ExBitget])
|
||||||
|
if reProfit < t.cfg.TradeThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkScaleIn adds more position when spread widens further.
|
||||||
|
func (t *Trader) checkScaleIn(pos *ArbPosition, bgP, hlP, diffPct float64, snap map[string]map[string]float64) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale in: add same amount again
|
||||||
|
pos.ScaleLevels++
|
||||||
|
pos.LastScaleAt = time.Now()
|
||||||
|
pos.AmountUSD += t.cfg.TradeAmountUSD
|
||||||
|
|
||||||
|
log.Printf("[Trader] %s: Scale-in #%d | 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.
|
||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrackedCoin represents a coin we monitor across exchanges.
|
||||||
|
type TrackedCoin struct {
|
||||||
|
Name string // Display name (BTC, ETH, etc.)
|
||||||
|
BN string // Binance symbol (BTCUSDT)
|
||||||
|
BG string // Bitget symbol (BTCUSDT)
|
||||||
|
HL string // HyperLiquid symbol (BTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceTick holds a price update with optional bid/ask.
|
||||||
|
type PriceTick struct {
|
||||||
|
Price float64
|
||||||
|
Bid float64 // 0 if unknown
|
||||||
|
Ask float64 // 0 if unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spread holds bid/ask data for one exchange+coin.
|
||||||
|
type Spread struct {
|
||||||
|
Bid float64
|
||||||
|
Ask float64
|
||||||
|
Updated int64 // unix nano
|
||||||
|
}
|
||||||
|
|
||||||
|
// PriceStore holds the latest prices from all exchanges, thread-safe.
|
||||||
|
type PriceStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
prices map[string]map[string]float64 // coin -> exchange -> price
|
||||||
|
spreads map[string]map[string]*Spread // coin -> exchange -> spread
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPriceStore() *PriceStore {
|
||||||
|
return &PriceStore{
|
||||||
|
prices: make(map[string]map[string]float64),
|
||||||
|
spreads: make(map[string]map[string]*Spread),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a price update. If bid/ask are non-zero, also stores spread.
|
||||||
|
func (s *PriceStore) Set(coin, exchange string, price float64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.prices[coin] == nil {
|
||||||
|
s.prices[coin] = make(map[string]float64)
|
||||||
|
}
|
||||||
|
s.prices[coin][exchange] = price
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWithSpread stores price + bid/ask spread.
|
||||||
|
func (s *PriceStore) SetWithSpread(coin, exchange string, price, bid, ask float64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.prices[coin] == nil {
|
||||||
|
s.prices[coin] = make(map[string]float64)
|
||||||
|
}
|
||||||
|
s.prices[coin][exchange] = price
|
||||||
|
|
||||||
|
if bid > 0 && ask > 0 {
|
||||||
|
if s.spreads[coin] == nil {
|
||||||
|
s.spreads[coin] = make(map[string]*Spread)
|
||||||
|
}
|
||||||
|
s.spreads[coin][exchange] = &Spread{
|
||||||
|
Bid: bid, Ask: ask,
|
||||||
|
Updated: time.Now().UnixNano(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PriceStore) Get(coin, exchange string) (float64, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
p, ok := s.prices[coin][exchange]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpread returns the current bid-ask spread (as percentage of mid price).
|
||||||
|
// Returns 0 if no spread data available.
|
||||||
|
func (s *PriceStore) GetSpread(coin, exchange string) float64 {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
sp, ok := s.spreads[coin][exchange]
|
||||||
|
if !ok || sp.Bid <= 0 || sp.Ask <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mid := (sp.Bid + sp.Ask) / 2
|
||||||
|
if mid <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (sp.Ask - sp.Bid) / mid * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns a snapshot of all prices.
|
||||||
|
func (s *PriceStore) GetAll() map[string]map[string]float64 {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
snap := make(map[string]map[string]float64)
|
||||||
|
for coin, exMap := range s.prices {
|
||||||
|
snap[coin] = make(map[string]float64)
|
||||||
|
for ex, p := range exMap {
|
||||||
|
snap[coin][ex] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArbOpportunity represents a profitable arbitrage route.
|
||||||
|
type ArbOpportunity struct {
|
||||||
|
Coin string
|
||||||
|
Direction string // e.g. "BN->HL"
|
||||||
|
BuyEx string
|
||||||
|
SellEx string
|
||||||
|
BuyPrice float64
|
||||||
|
SellPrice float64
|
||||||
|
NetProfit float64 // percentage after fees
|
||||||
|
GrossBasis float64 // raw price difference %
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user