Initial commit

This commit is contained in:
jackyu66git
2026-05-03 16:54:36 +08:00
commit 719f0a061d
22 changed files with 2436 additions and 0 deletions
+39
View File
@@ -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
View File
@@ -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/
+83
View File
@@ -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", ""),
}
}
+129
View File
@@ -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()
}
+79
View File
@@ -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()
}
+95
View File
@@ -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()
}
+138
View File
@@ -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)
}
}
+168
View File
@@ -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 ""
}
+174
View File
@@ -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
}
+42
View File
@@ -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)
}
+66
View File
@@ -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()
}
+188
View File
@@ -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)
}
}
+16
View File
@@ -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
)
+5
View File
@@ -0,0 +1,5 @@
module exchange-monitor
go 1.23.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2
View File
@@ -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=
+210
View File
@@ -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)
}
}
}
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "??"
}
+557
View File
@@ -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
}
+121
View File
@@ -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 %
}