feat: 添加OKX行情接入+趋势检测+累积变动系统+界面重构
- 新增OKX WebSocket行情连接器,扩展4交易所价格监控 - 新增z-score趋势检测引擎(TrendDetector),识别价格异动/趋势启动 - 新增累积变动跟踪(CumulativeTracker),基于1min/5min多交易所共识 - 趋势事件和累积变动事件持久化到SQLite - 新增Binance/OKX动量检测字段,扩展前端动量卡片至15列 - 迁移至macOS(darwin-arm64),更新前端依赖 - Dashboard网格重构:非交易卡片置顶,交易卡片置底 - TrackedCoin添加OK字段,添加ExBinance/ExOKX常量 - 前端新增趋势检测卡片、趋势历史卡片、累积变动卡片 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
047571921e
commit
b7767c95ae
+56
-18
@@ -4,39 +4,50 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BinanceWS connects to Binance WS for ticker data.
|
||||
// Splits symbols across multiple combined-stream connections.
|
||||
type BinanceWS struct {
|
||||
Tracked []string
|
||||
Tracked []string
|
||||
connections int
|
||||
}
|
||||
|
||||
func NewBinanceWS(tracked []string) *BinanceWS {
|
||||
return &BinanceWS{Tracked: tracked}
|
||||
conns := int(math.Ceil(float64(len(tracked)) / 60))
|
||||
if conns < 1 {
|
||||
conns = 1
|
||||
}
|
||||
if conns > 10 {
|
||||
conns = 10
|
||||
}
|
||||
return &BinanceWS{Tracked: tracked, connections: conns}
|
||||
}
|
||||
// Run connects to Binance WS and streams bookTicker data.
|
||||
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||
|
||||
func (b *BinanceWS) runSingle(symbols []string, connIdx int, updateFn func(coin string, price, bid, ask float64)) error {
|
||||
streams := ""
|
||||
for i, sym := range b.Tracked {
|
||||
for i, sym := range symbols {
|
||||
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
|
||||
url := fmt.Sprintf("wss://fstream.binance.com/stream?streams=%s", streams)
|
||||
name := fmt.Sprintf("Binance-%d", connIdx)
|
||||
|
||||
conn := NewPriceConnector(url, name, 60*time.Second, 15*time.Second)
|
||||
// No client-side pings — let the proxy handle keepalive
|
||||
conn.PingInterval = 0
|
||||
|
||||
conn.OnConnect = func() {
|
||||
log.Printf("[Binance WS] Connected")
|
||||
log.Printf("[%s] Connected (%d symbols)", name, len(symbols))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -46,12 +57,12 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
||||
return
|
||||
}
|
||||
|
||||
// Parse data object as flat map to extract fields by exact name
|
||||
// Parse data as a generic map to avoid field name conflicts
|
||||
// (bookTicker has both "b" bid price and "B" bid quantity)
|
||||
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)
|
||||
@@ -59,13 +70,12 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
||||
return
|
||||
}
|
||||
|
||||
bid, err1 := strconv.ParseFloat(bidStr, 64)
|
||||
ask, err2 := strconv.ParseFloat(askStr, 64)
|
||||
if err1 != nil || err2 != nil || bid <= 0 || ask <= 0 {
|
||||
bid := parseFloat(bidStr)
|
||||
ask := parseFloat(askStr)
|
||||
if bid <= 0 || ask <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract coin name (e.g., "BTCUSDT" -> "BTC")
|
||||
coin := symbolToCoin(symbol, "USDT")
|
||||
if coin == "" {
|
||||
return
|
||||
@@ -77,3 +87,31 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
||||
|
||||
return conn.Run()
|
||||
}
|
||||
|
||||
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||
if len(b.Tracked) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
n := b.connections
|
||||
perConn := (len(b.Tracked) + n - 1) / n
|
||||
|
||||
errCh := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
start := i * perConn
|
||||
end := start + perConn
|
||||
if end > len(b.Tracked) {
|
||||
end = len(b.Tracked)
|
||||
}
|
||||
if start >= end {
|
||||
errCh <- nil
|
||||
continue
|
||||
}
|
||||
batch := b.Tracked[start:end]
|
||||
go func(idx int, syms []string) {
|
||||
errCh <- b.runSingle(syms, idx, updateFn)
|
||||
}(i+1, batch)
|
||||
}
|
||||
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OKXWS connects to OKX WebSocket for tickers channel (perpetual swaps).
|
||||
type OKXWS struct {
|
||||
Tracked []string // OKX symbols like BTC-USDT-SWAP
|
||||
}
|
||||
|
||||
type okxSubscribeMsg struct {
|
||||
Op string `json:"op"`
|
||||
Args []okxChannel `json:"args"`
|
||||
}
|
||||
|
||||
type okxChannel struct {
|
||||
Channel string `json:"channel"`
|
||||
InstID string `json:"instId"`
|
||||
}
|
||||
|
||||
type okxTickerMsg struct {
|
||||
Arg okxChannel `json:"arg"`
|
||||
Data []okxTickerData `json:"data"`
|
||||
}
|
||||
|
||||
type okxTickerData struct {
|
||||
Last string `json:"last"`
|
||||
BidPx string `json:"bidPx"`
|
||||
AskPx string `json:"askPx"`
|
||||
}
|
||||
|
||||
func NewOKXWS(tracked []string) *OKXWS {
|
||||
return &OKXWS{
|
||||
Tracked: tracked,
|
||||
}
|
||||
}
|
||||
|
||||
// Run connects to OKX WS and streams ticker data.
|
||||
func (o *OKXWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||
url := "wss://ws.okx.com:8443/ws/v5/public"
|
||||
|
||||
conn := NewPriceConnector(url, "OKX", 120*time.Second, 30*time.Second)
|
||||
conn.PingInterval = 20 * time.Second // OKX requires ping within 30s
|
||||
conn.TextPing = true // OKX expects text "ping" message
|
||||
|
||||
conn.OnConnect = func() {
|
||||
log.Printf("[OKX WS] Connected, subscribing (%d symbols)", len(o.Tracked))
|
||||
|
||||
// Batch subscriptions — OKX has rate limits (3 req/s, 480/hr)
|
||||
batchSize := 20
|
||||
for i := 0; i < len(o.Tracked); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(o.Tracked) {
|
||||
end = len(o.Tracked)
|
||||
}
|
||||
batch := o.Tracked[i:end]
|
||||
args := make([]okxChannel, len(batch))
|
||||
for j, sym := range batch {
|
||||
args[j] = okxChannel{
|
||||
Channel: "tickers",
|
||||
InstID: sym,
|
||||
}
|
||||
}
|
||||
sub := okxSubscribeMsg{
|
||||
Op: "subscribe",
|
||||
Args: args,
|
||||
}
|
||||
if err := conn.SendJSON(sub); err != nil {
|
||||
log.Printf("[OKX WS] Subscribe error (batch %d): %v", i/batchSize, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn.OnMessage = func(msg []byte) {
|
||||
// Handle OKX text "pong" response
|
||||
if string(msg) == "pong" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for subscription confirmation or error response
|
||||
var generic map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &generic); err == nil {
|
||||
if evt, _ := generic["event"].(string); evt == "error" || evt == "subscribe" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var ticker okxTickerMsg
|
||||
if err := json.Unmarshal(msg, &ticker); err != nil {
|
||||
return
|
||||
}
|
||||
if len(ticker.Data) == 0 || ticker.Data[0].Last == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert BTC-USDT-SWAP -> BTC
|
||||
coin := okxSymbolToCoin(ticker.Arg.InstID)
|
||||
if coin == "" {
|
||||
return
|
||||
}
|
||||
|
||||
price := parseFloat(ticker.Data[0].Last)
|
||||
if price > 0 {
|
||||
bid := parseFloat(ticker.Data[0].BidPx)
|
||||
ask := parseFloat(ticker.Data[0].AskPx)
|
||||
updateFn(coin, price, bid, ask)
|
||||
}
|
||||
}
|
||||
|
||||
return conn.Run()
|
||||
}
|
||||
|
||||
// okxSymbolToCoin converts "BTC-USDT-SWAP" to "BTC".
|
||||
func okxSymbolToCoin(symbol string) string {
|
||||
// Strip "-USDT-SWAP" suffix
|
||||
const suffix = "-USDT-SWAP"
|
||||
if !strings.HasSuffix(symbol, suffix) {
|
||||
return ""
|
||||
}
|
||||
return symbol[:len(symbol)-len(suffix)]
|
||||
}
|
||||
Reference in New Issue
Block a user