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