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:
jackyu66git
2026-05-06 13:26:05 +08:00
co-authored by Claude Opus 4.6
parent 047571921e
commit b7767c95ae
24 changed files with 2705 additions and 273 deletions
+113
View File
@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Cross-exchange perpetual contract arbitrage system between Bitget and HyperLiquid. Scans ~150 coins for price spreads, executes automated arbitrage trades with scale-in/exit logic, and displays real-time data on a React dashboard.
## Build & Run Commands
```bash
# Build Go binary
go build -o exchange-monitor .
# Start (kills old process + builds if needed + runs)
bash start.sh
# Options: --clean (delete DB), --rebuild (force recompile)
bash start.sh --clean --rebuild
# Frontend dev (hot reload on :5173, proxies /api to :8888)
cd frontend && npm run dev
# Frontend production build
cd frontend && npm run build
# IPC commands (talk to running daemon via Unix socket)
./exchange-monitor status
./exchange-monitor close-all
./exchange-monitor close DOGE
./exchange-monitor stop
./exchange-monitor start
```
## Architecture
### Data Flow
```
Exchange WS (HL + BG) → PriceStore (in-memory) → scanner → Trader (entry/exit/scale)
dashboard (SSE hub, :8888)
React frontend (SSE events)
```
### Main Loop (main.go:148-245)
Fixed 50ms tick: trader.Tick() → scan.scanBGHL() → TryEntry() for each opportunity. Every 30s: status log. Hourly: Telegram summary.
### Package Layout
| Package | Files | Responsibility |
|---------|-------|---------------|
| `main` (root) | `main.go`, `scanner.go`, `trader.go`, `dashboard.go`, `config.go`, `types.go`, `notifier.go`, `ipc.go` | All core logic in a single flat package |
| `exchange/` | `connector.go`, `bitget.go`, `hyperliquid.go`, `bitget_trade.go`, `hyperliquid_trade.go`, `helpers.go` | WS reconnector + exchange-specific REST/WS APIs |
| `db/` | `db.go`, `trade_repo.go` | SQLite persistence (trades, orders, system_orders, config_log) |
| `frontend/` | Vite + React | Real-time dashboard consuming SSE from backend |
### Key Types
- **PriceStore** — Thread-safe in-memory map of coin→exchange→price, with bid/ask spread tracking
- **ArbOpportunity** — Scanning result: coin, direction (BG→HL or HL→BG), prices, net profit %
- **ArbPosition** — Open position with long/short legs, scale-in tracking, entry prices array
- **Trader** — Manages positions, entry/exit logic, fund tracking, blacklist, DB persistence
### Exchange Connector
`PriceConnector` (exchange/connector.go) is a reusable WebSocket reconnector with exponential backoff (1s-30s), configurable ping interval, and read deadline. Bitget uses text ping frames; HyperLiquid uses standard ping/pong.
### Trading Logic
- **Entry (TryEntry → executeEntry)**: Checks threshold, margin, blacklist, cooldown, max positions. Places both legs asynchronously with 300ms delay. Persists DB record immediately on "entering" status for crash recovery.
- **Scale-in (checkScaleIn)**: Adds position when spread widens by ScaleStepPct per level. Posts additional orders on both legs.
- **Exit (checkExit)**: Take profit at threshold, converged spread ≤ 0.02%, or timeout. Retries failed closes up to 30 times.
- **Blacklist**: Force-closes position open >10min without convergence, prevents re-entry for BlacklistDuration.
### Net Profit Calculation
```go
netProfit(buyPrice, sellPrice, buyFee, sellFee) = (revenue/cost - 1)*100 - 2*(buyFee + sellFee)
```
Where cost = buyPrice * (1 + buyFee/100), revenue = sellPrice * (1 - sellFee/100). Four total fees (2 entry + 2 exit).
### Configuration Priority
`.env` vars > `config.json` > code defaults. Config struct in `config.go`.
Key env vars: `BITGET_API_KEY`, `BITGET_API_SECRET`, `BITGET_PASSPHRASE`, `HL_PRIVATE_KEY`, `HL_ADDRESS`, `HL_API_ADDRESS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TRADE_ENABLED`, `TEST_MODE`.
### Dashboard API
| Endpoint | Description |
|----------|-------------|
| `GET /` | Serves index.html (disk first, fallback embed) |
| `GET /api/status` | Prices, positions, stats, exchange funds |
| `GET /events` | SSE stream (prices, positions, arb, stats — 1s interval) |
| `GET /api/history?coin=&exchange=` | Price history ring buffer (500 pts) |
| `GET /api/spread-history?coin=` | BG↔HL spread history |
| `GET /api/trades?page=&limit=&coin=` | Paginated trade history from DB |
| `GET /api/trade/{id}` | Trade detail + orders |
| `GET /api/connections` | Exchange WS health (online/stale/offline) |
| `POST /api/stop` | Stop trading + force-close positions |
| `POST /api/start` | Resume trading |
### Database
SQLite at `~/Project/exchange-monitor-go/data/trades.db` (single-writer mode). Tables: `trades` (trade-level), `orders` (per-leg filled orders), `system_orders` (linked long+short order pairs), `config_log`.
### Coin Tracking
~150 coins in `TrackedCoins` slice (scanner.go). Each entry has Name, BN (Binance, currently unused), BG (Bitget symbol), HL (HyperLiquid symbol). Only BG+HL are actively connected.
### IPC (Unix Socket)
`/tmp/exchange-monitor.sock` — JSON commands from CLI to daemon. Actions: status, close-all, close {coin}, stop, start.
+62
View File
@@ -52,6 +52,17 @@ type Config struct {
// Entry sanity check: reject if price moved beyond this % in the wrong direction
ReversalTolerancePct float64
// Momentum scanning mode
MomentumEnabled bool
MomentumThresholdPct float64
// Trend detection mode
TrendEnabled bool
TrendBaselineWindow int // ticks for EMA volatility baseline (default: 600 = 30s)
TrendAnomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
TrendConfirmTicks int // ticks needed for state confirmation (default: 3)
TrendAlertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
// Bitget API
BitgetAPIKey string
BitgetAPISecret string
@@ -79,6 +90,17 @@ type jsonConfig struct {
InitialCapital float64 `json:"initial_capital"`
ExcludedCoins []string `json:"excluded_coins"`
// Momentum scanning
MomentumEnabled bool `json:"momentum_enabled"`
MomentumThresholdPct float64 `json:"momentum_threshold_pct"`
// Trend detection
TrendEnabled bool `json:"trend_enabled"`
TrendBaselineWindow int `json:"trend_baseline_window"`
TrendAnomalyMul float64 `json:"trend_anomaly_mul"`
TrendConfirmTicks int `json:"trend_confirm_ticks"`
TrendAlertCooldown int64 `json:"trend_alert_cooldown_ms"`
// New: exchange fees
TakerFeeBitget float64 `json:"taker_fee_bitget"`
TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"`
@@ -158,6 +180,17 @@ func LoadConfig() *Config {
ExcludedCoins: jsonCfg.ExcludedCoins,
// Momentum scanning
MomentumEnabled: getBool("MOMENTUM_ENABLED", jsonCfg.MomentumEnabled),
MomentumThresholdPct: getFloat("MOMENTUM_THRESHOLD_PCT", jsonCfg.MomentumThresholdPct),
// Trend detection
TrendEnabled: getBool("TREND_ENABLED", jsonCfg.TrendEnabled),
TrendBaselineWindow: int(getFloat("TREND_BASELINE_WINDOW", float64(jsonCfg.TrendBaselineWindow))),
TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul),
TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))),
TrendAlertCooldown: int64(getFloat("TREND_ALERT_COOLDOWN_MS", float64(jsonCfg.TrendAlertCooldown))),
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
@@ -194,6 +227,15 @@ func loadJSONConfig() jsonConfig {
// Scale-in parameters
ScaleStepPct: 0.10, // 0.10% spread widening per scale level
ScaleCooldownSec: 5, // 5 seconds between scales
// Momentum scanning
MomentumThresholdPct: 0.25, // 0.25% change flags momentum
// Trend detection
TrendBaselineWindow: 600, // ~30s at 50ms tick
TrendAnomalyMul: 3.0, // 3 sigma z-score threshold
TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation
TrendAlertCooldown: 60000, // 1 min cooldown
}
data, err := os.ReadFile("config.json")
@@ -267,11 +309,31 @@ func loadJSONConfig() jsonConfig {
def.ExcludedCoins = cfg.ExcludedCoins
}
if cfg.MomentumThresholdPct != 0 {
def.MomentumThresholdPct = cfg.MomentumThresholdPct
}
// Trend detection JSON overrides
if cfg.TrendBaselineWindow != 0 {
def.TrendBaselineWindow = cfg.TrendBaselineWindow
}
if cfg.TrendAnomalyMul != 0 {
def.TrendAnomalyMul = cfg.TrendAnomalyMul
}
if cfg.TrendConfirmTicks != 0 {
def.TrendConfirmTicks = cfg.TrendConfirmTicks
}
if cfg.TrendAlertCooldown != 0 {
def.TrendAlertCooldown = cfg.TrendAlertCooldown
}
// Boolean fields: zero default is false, so use OR logic
// When JSON has true → true || false = true (override)
// When JSON has false → false || false = false (keep default)
def.TestMode = cfg.TestMode || def.TestMode
def.TradeEnabled = cfg.TradeEnabled || def.TradeEnabled
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
return def
}
+4 -1
View File
@@ -19,5 +19,8 @@
"leg_delay_ms": 300,
"reversal_tolerance_pct": 0.1,
"scale_step_pct": 0.3,
"scale_cooldown_sec": 5
"scale_cooldown_sec": 5,
"momentum_enabled": true,
"momentum_threshold_pct": 0.25,
"trend_enabled": true
}
+447
View File
@@ -0,0 +1,447 @@
package main
import (
"math"
"sort"
"sync"
"time"
)
// CmState represents a coin's cumulative move state.
type CmState string
const (
CmNeutral CmState = "neutral"
CmRising CmState = "rising" // strong upward consensus across exchanges
CmFalling CmState = "falling" // strong downward consensus across exchanges
)
// exChange holds a per-exchange price change percentage.
type exChange struct {
name string
change float64
}
// CmEvent records a cumulative move state transition, persisted to DB.
type CmEvent struct {
Coin string `json:"coin"`
PrevState string `json:"prev_state"`
NewState string `json:"new_state"`
Direction string `json:"direction"`
Score float64 `json:"score"` // avg_change% × ex_agree
AvgChange float64 `json:"avg_change"` // average change% across all exchanges
ExAgree int `json:"ex_agree"`
ExTotal int `json:"ex_total"`
BGChange1m float64 `json:"bg_1m"`
HLChange1m float64 `json:"hl_1m"`
BNChange1m float64 `json:"bn_1m"`
OKXChange1m float64 `json:"okx_1m"`
BGChange5m float64 `json:"bg_5m"`
HLChange5m float64 `json:"hl_5m"`
BNChange5m float64 `json:"bn_5m"`
OKXChange5m float64 `json:"okx_5m"`
Timestamp int64 `json:"timestamp"`
}
// CmSnapshot is a point-in-time price snapshot for all exchanges for one coin.
type cmSnapshot struct {
time int64
prices map[string]float64 // exchange → price
}
// CumulativeTracker monitors multi-exchange cumulative price changes.
// Takes 1-second snapshots, computes 1m/5m changes, detects consensus surges.
type CumulativeTracker struct {
mu sync.RWMutex
coins map[string][]cmSnapshot // coin → ring buffer of snapshots
heads map[string]int
counts map[string]int
// Per-coin state
states map[string]CmState
prevState map[string]CmState
// Ring buffer config
maxSnapshots int // 5min worth at 1s = 300
// Thresholds
minExchanges int // need at least this many exchanges with data (default: 3)
surgePct1m float64 // 1m change% threshold to trigger (default: 0.5%)
surgePct5m float64 // 5m change% threshold to trigger (default: 1.0%)
// Event history (in-memory ring buffer)
events [maxTrendEvents]CmEvent
eventsHead int
eventsLen int
// Callback for DB persistence
OnEvent func(CmEvent)
}
// NewCumulativeTracker creates a tracker with default thresholds.
func NewCumulativeTracker() *CumulativeTracker {
return &CumulativeTracker{
coins: make(map[string][]cmSnapshot),
heads: make(map[string]int),
counts: make(map[string]int),
states: make(map[string]CmState),
prevState: make(map[string]CmState),
maxSnapshots: 300, // 5min at 1s
minExchanges: 3,
surgePct1m: 0.5, // 0.5% in 1min
surgePct5m: 1.0, // 1.0% in 5min
}
}
// Record stores a price snapshot for a coin at the current time.
// Call this once per second with all exchange prices for each coin.
func (ct *CumulativeTracker) Record(coin string, prices map[string]float64) {
ct.mu.Lock()
defer ct.mu.Unlock()
snap := cmSnapshot{
time: time.Now().UnixMilli(),
prices: prices,
}
// Initialize buffer if needed
if ct.coins[coin] == nil {
ct.coins[coin] = make([]cmSnapshot, ct.maxSnapshots)
ct.heads[coin] = 0
ct.counts[coin] = 0
ct.states[coin] = CmNeutral
ct.prevState[coin] = CmNeutral
}
buf := ct.coins[coin]
head := ct.heads[coin]
buf[head] = snap
ct.heads[coin] = (head + 1) % ct.maxSnapshots
if ct.counts[coin] < ct.maxSnapshots {
ct.counts[coin]++
}
}
// GetCurrent returns current cumulative change info for all coins, sorted by score desc.
func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
ct.mu.RLock()
defer ct.mu.RUnlock()
var results []map[string]interface{}
for coin, buf := range ct.coins {
count := ct.counts[coin]
if count < 10 {
continue // not enough data
}
head := ct.heads[coin]
// Get current snapshot (most recent)
currentIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
current := buf[currentIdx]
if current.time == 0 {
continue
}
if len(current.prices) < ct.minExchanges {
continue
}
// Find snapshots from ~60s ago and ~300s ago
now := current.time
oneMinAgo := now - 60000
fiveMinAgo := now - 300000
var snap1m, snap5m *cmSnapshot
var found1m, found5m bool
// Walk backwards from current to find closest snapshots
for i := 0; i < count && i < ct.maxSnapshots; i++ {
idx := (currentIdx - i + ct.maxSnapshots) % ct.maxSnapshots
s := &buf[idx]
if s.time == 0 {
continue
}
if !found1m && s.time <= oneMinAgo {
snap1m = s
found1m = true
}
if !found5m && s.time <= fiveMinAgo {
snap5m = s
found5m = true
}
}
if !found1m {
// Use oldest available as 1m approximation
continue
}
// Compute 1m changes per exchange
var changes1m, changes5m []exChange
for ex, curP := range current.prices {
if curP <= 0 {
continue
}
if oldP, ok := snap1m.prices[ex]; ok && oldP > 0 {
chg := (curP - oldP) / oldP * 100
changes1m = append(changes1m, exChange{name: ex, change: chg})
}
if found5m && snap5m != nil {
if oldP, ok := snap5m.prices[ex]; ok && oldP > 0 {
chg := (curP - oldP) / oldP * 100
changes5m = append(changes5m, exChange{name: ex, change: chg})
}
}
}
if len(changes1m) < ct.minExchanges {
continue
}
// Compute averages and agreement
var sum1m, sum5m float64
agreeUp1m, agreeDown1m := 0, 0
agreeUp5m, agreeDown5m := 0, 0
for _, c := range changes1m {
sum1m += c.change
if c.change > 0.001 {
agreeUp1m++
} else if c.change < -0.001 {
agreeDown1m++
}
}
for _, c := range changes5m {
sum5m += c.change
if c.change > 0.005 {
agreeUp5m++
} else if c.change < -0.005 {
agreeDown5m++
}
}
avg1m := sum1m / float64(len(changes1m))
var avg5m float64
if len(changes5m) >= ct.minExchanges {
avg5m = sum5m / float64(len(changes5m))
}
// Determine direction and agreement
majorityDir := "up"
majority := agreeUp1m
if agreeDown1m > agreeUp1m {
majorityDir = "down"
majority = agreeDown1m
}
// Score: abs(avg1m) × agreement (weighted by magnitude)
absAvg := math.Abs(avg1m)
score := absAvg * float64(majority)
entry := map[string]interface{}{
"coin": coin,
"avg_1m": math.Round(avg1m*10000) / 10000,
"avg_5m": math.Round(avg5m*10000) / 10000,
"score": math.Round(score*100) / 100,
"direction": majorityDir,
"ex_agree": majority,
"ex_total": len(changes1m),
}
// Individual exchange changes
for _, c := range changes1m {
entry[c.name+"_1m"] = math.Round(c.change*10000) / 10000
}
if len(changes5m) >= ct.minExchanges {
for _, c := range changes5m {
entry[c.name+"_5m"] = math.Round(c.change*10000) / 10000
}
}
// Current state
entry["state"] = string(ct.states[coin])
results = append(results, entry)
}
// Sort by score descending
sort.Slice(results, func(i, j int) bool {
si, _ := results[i]["score"].(float64)
sj, _ := results[j]["score"].(float64)
return si > sj
})
if len(results) > 100 {
results = results[:100]
}
return results
}
// Tick runs one detection cycle: updates state machines, fires events.
func (ct *CumulativeTracker) Tick() {
ct.mu.Lock()
defer ct.mu.Unlock()
for coin, buf := range ct.coins {
count := ct.counts[coin]
if count < 60 {
continue // need at least 1min of data
}
head := ct.heads[coin]
currentIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
current := buf[currentIdx]
if current.time == 0 || len(current.prices) < ct.minExchanges {
continue
}
// Find 1min ago snapshot
oneMinAgo := current.time - 60000
var snap1m *cmSnapshot
for i := 0; i < count && i < ct.maxSnapshots; i++ {
idx := (currentIdx - i + ct.maxSnapshots) % ct.maxSnapshots
s := &buf[idx]
if s.time > 0 && s.time <= oneMinAgo {
snap1m = s
break
}
}
if snap1m == nil {
continue
}
// Compute 1m changes
var changes []exChange
for ex, curP := range current.prices {
if curP <= 0 {
continue
}
if oldP, ok := snap1m.prices[ex]; ok && oldP > 0 {
chg := (curP - oldP) / oldP * 100
changes = append(changes, exChange{name: ex, change: chg})
}
}
if len(changes) < ct.minExchanges {
continue
}
var sum float64
agreeUp, agreeDown := 0, 0
for _, c := range changes {
sum += c.change
if c.change > 0.001 {
agreeUp++
} else if c.change < -0.001 {
agreeDown++
}
}
avg := sum / float64(len(changes))
majority := agreeUp
majorityDir := "up"
if agreeDown > agreeUp {
majority = agreeDown
majorityDir = "down"
}
// Determine new state
absAvg := math.Abs(avg)
newState := ct.states[coin]
// Map exchange changes for individual values
exMap := make(map[string]float64)
for _, c := range changes {
exMap[c.name] = c.change
}
if absAvg >= ct.surgePct1m && majority >= ct.minExchanges {
if majorityDir == "up" {
if ct.states[coin] == CmNeutral || ct.states[coin] == CmFalling {
ct.prevState[coin] = ct.states[coin]
ct.states[coin] = CmRising
newState = CmRising
// Fire event
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "rising", majorityDir,
absAvg*float64(majority), avg, majority, len(changes), exMap)
ct.storeEvent(ev)
}
} else {
if ct.states[coin] == CmNeutral || ct.states[coin] == CmRising {
ct.prevState[coin] = ct.states[coin]
ct.states[coin] = CmFalling
newState = CmFalling
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "falling", majorityDir,
absAvg*float64(majority), avg, majority, len(changes), exMap)
ct.storeEvent(ev)
}
}
} else if absAvg < ct.surgePct1m*0.3 || majority < 2 {
if ct.states[coin] != CmNeutral {
ct.prevState[coin] = ct.states[coin]
ct.states[coin] = CmNeutral
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "neutral", majorityDir,
absAvg*float64(majority), avg, majority, len(changes), exMap)
ct.storeEvent(ev)
}
}
_ = newState
}
}
// makeEvent builds a CmEvent struct with 1m and 5m data.
func (ct *CumulativeTracker) makeEvent(coin, prevState, newState, direction string, score, avgChange float64, exAgree, exTotal int, exChanges map[string]float64) CmEvent {
return CmEvent{
Coin: coin,
PrevState: prevState,
NewState: newState,
Direction: direction,
Score: math.Round(score*100) / 100,
AvgChange: math.Round(avgChange*10000) / 10000,
ExAgree: exAgree,
ExTotal: exTotal,
BGChange1m: exChanges[ExBitget],
HLChange1m: exChanges[ExHyperLiquid],
BNChange1m: exChanges[ExBinance],
OKXChange1m: exChanges[ExOKX],
Timestamp: time.Now().UnixMilli(),
}
}
// storeEvent adds to ring buffer and fires callback.
func (ct *CumulativeTracker) storeEvent(ev CmEvent) {
ct.events[ct.eventsHead] = ev
ct.eventsHead = (ct.eventsHead + 1) % maxTrendEvents
if ct.eventsLen < maxTrendEvents {
ct.eventsLen++
}
if ct.OnEvent != nil {
ct.OnEvent(ev)
}
}
// GetEvents returns stored events, newest first.
func (ct *CumulativeTracker) GetEvents(limit int) []CmEvent {
ct.mu.RLock()
defer ct.mu.RUnlock()
n := ct.eventsLen
if limit > 0 && limit < n {
n = limit
}
result := make([]CmEvent, 0, n)
for i := 0; i < n; i++ {
idx := (ct.eventsHead - 1 - i + maxTrendEvents) % maxTrendEvents
if ct.events[idx].Timestamp == 0 {
continue
}
result = append(result, ct.events[idx])
}
return result
}
// GetTopCoins returns top surging coins by score.
func (ct *CumulativeTracker) GetTopCoins(limit int) []map[string]interface{} {
all := ct.GetCurrent()
if limit > 0 && limit < len(all) {
return all[:limit]
}
return all
}
+102 -2
View File
@@ -189,6 +189,7 @@ type Dashboard struct {
trader *Trader
db *db.DB
addr string
cfg *Config
// cached arb scan results
mu sync.RWMutex
@@ -198,10 +199,19 @@ type Dashboard struct {
// P3-5: connection status — exchange -> last update time
connMu sync.RWMutex
connMap map[string]time.Time // exchange name -> last price timestamp
// Momentum tracker
momentumTracker *MomentumTracker
// Trend detector
trendDetector *TrendDetector
// Cumulative tracker (1m/5m multi-exchange consensus)
cumulativeTracker *CumulativeTracker
}
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string) *Dashboard {
return &Dashboard{
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker) *Dashboard {
d := &Dashboard{
hub: NewSSEHub(),
history: newPriceHistory(),
spreads: newSpreadHistory(),
@@ -209,8 +219,33 @@ func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr strin
trader: trader,
db: database,
addr: addr,
cfg: cfg,
connMap: make(map[string]time.Time),
momentumTracker: momentumTracker,
trendDetector: trendDetector,
cumulativeTracker: cumulativeTracker,
}
// Wire trend event persistence to SQLite
if trendDetector != nil && database != nil {
trendDetector.OnEvent = func(ev TrendEvent) {
database.InsertTrendEvent(ev.Coin, ev.PrevState, ev.NewState, ev.Direction,
ev.ZScore, ev.Volatility, ev.BGChange, ev.HLChange, ev.BNChange, ev.OKXChange,
ev.ExAgree, ev.ExTotal)
}
}
// Wire cumulative event persistence to SQLite
if cumulativeTracker != nil && database != nil {
cumulativeTracker.OnEvent = func(ev CmEvent) {
database.InsertCmEvent(ev.Coin, ev.PrevState, ev.NewState, ev.Direction,
ev.Score, ev.AvgChange, ev.ExAgree, ev.ExTotal,
ev.BGChange1m, ev.HLChange1m, ev.BNChange1m, ev.OKXChange1m,
ev.BGChange5m, ev.HLChange5m, ev.BNChange5m, ev.OKXChange5m)
}
}
return d
}
func (d *Dashboard) Run() {
@@ -239,6 +274,8 @@ func (d *Dashboard) Run() {
mux.HandleFunc("GET /api/trades", d.handleTrades)
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
mux.HandleFunc("GET /api/trend-history", d.handleTrendHistory)
mux.HandleFunc("GET /api/cm-history", d.handleCmHistory)
mux.HandleFunc("GET /events", d.handleSSE)
mux.HandleFunc("POST /api/stop", d.handleStop)
mux.HandleFunc("POST /api/start", d.handleStart)
@@ -534,6 +571,32 @@ func (d *Dashboard) broadcastLoop() {
stats["blacklist"] = blList
d.hub.Broadcast("stats", stats)
// 5. Momentum data (if enabled and tracker is available)
if d.momentumTracker != nil && d.cfg.MomentumEnabled {
momentumData := d.momentumTracker.Snapshot(d.cfg.MomentumThresholdPct)
if len(momentumData) > 0 {
d.hub.Broadcast("momentum", momentumData)
}
}
// 6. Trend detection (if enabled)
if d.trendDetector != nil && d.cfg.TrendEnabled {
d.trendDetector.Tick()
trendData := d.trendDetector.Snapshot()
if len(trendData) > 0 {
d.hub.Broadcast("trend", trendData)
}
}
// 7. Cumulative change tracking (always on if tracker exists)
if d.cumulativeTracker != nil {
d.cumulativeTracker.Tick()
cmData := d.cumulativeTracker.GetTopCoins(30)
if len(cmData) > 0 {
d.hub.Broadcast("cumulative", cmData)
}
}
}
}
@@ -664,6 +727,43 @@ func (d *Dashboard) handleConnStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, conns)
}
func (d *Dashboard) handleTrendHistory(w http.ResponseWriter, r *http.Request) {
var events interface{}
if d.db != nil {
records, err := d.db.GetTrendEvents(200)
if err == nil {
events = records
}
}
if events == nil {
// Fallback to in-memory ring buffer
if d.trendDetector != nil {
events = d.trendDetector.GetEvents(200)
} else {
events = []interface{}{}
}
}
writeJSON(w, map[string]interface{}{"events": events})
}
func (d *Dashboard) handleCmHistory(w http.ResponseWriter, r *http.Request) {
var events interface{}
if d.db != nil {
records, err := d.db.GetCmEvents(200)
if err == nil {
events = records
}
}
if events == nil {
if d.cumulativeTracker != nil {
events = d.cumulativeTracker.GetEvents(200)
} else {
events = []interface{}{}
}
}
writeJSON(w, map[string]interface{}{"events": events})
}
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
if d.db == nil {
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
+65
View File
@@ -0,0 +1,65 @@
package db
import (
"time"
)
// CmEventRecord represents a persisted cumulative move state transition.
type CmEventRecord struct {
ID int64 `json:"id"`
Coin string `json:"coin"`
PrevState string `json:"prev_state"`
NewState string `json:"new_state"`
Direction string `json:"direction"`
Score float64 `json:"score"`
AvgChange float64 `json:"avg_change"`
ExAgree int `json:"ex_agree"`
ExTotal int `json:"ex_total"`
BG1m float64 `json:"bg_1m"`
HL1m float64 `json:"hl_1m"`
BN1m float64 `json:"bn_1m"`
OKX1m float64 `json:"okx_1m"`
BG5m float64 `json:"bg_5m"`
HL5m float64 `json:"hl_5m"`
BN5m float64 `json:"bn_5m"`
OKX5m float64 `json:"okx_5m"`
CreatedAt string `json:"created_at"`
}
// InsertCmEvent saves a cumulative move event to the database.
func (d *DB) InsertCmEvent(coin, prevState, newState, direction string, score, avgChange float64, exAgree, exTotal int, bg1m, hl1m, bn1m, okx1m, bg5m, hl5m, bn5m, okx5m float64) error {
_, err := d.Exec(`
INSERT INTO cm_events (coin, prev_state, new_state, direction, score, avg_change, ex_agree, ex_total, bg_1m, hl_1m, bn_1m, okx_1m, bg_5m, hl_5m, bn_5m, okx_5m, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
coin, prevState, newState, direction, score, avgChange, exAgree, exTotal, bg1m, hl1m, bn1m, okx1m, bg5m, hl5m, bn5m, okx5m, Now().Format(time.RFC3339))
return err
}
// GetCmEvents returns cumulative move events ordered by creation time descending.
func (d *DB) GetCmEvents(limit int) ([]CmEventRecord, error) {
if limit <= 0 {
limit = 100
}
rows, err := d.Query(`
SELECT id, coin, prev_state, new_state, direction, score, avg_change, ex_agree, ex_total, bg_1m, hl_1m, bn_1m, okx_1m, bg_5m, hl_5m, bn_5m, okx_5m, created_at
FROM cm_events
ORDER BY created_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []CmEventRecord
for rows.Next() {
var r CmEventRecord
if err := rows.Scan(&r.ID, &r.Coin, &r.PrevState, &r.NewState, &r.Direction,
&r.Score, &r.AvgChange, &r.ExAgree, &r.ExTotal,
&r.BG1m, &r.HL1m, &r.BN1m, &r.OKX1m,
&r.BG5m, &r.HL5m, &r.BN5m, &r.OKX5m, &r.CreatedAt); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
+42
View File
@@ -108,6 +108,48 @@ func (d *DB) migrate() error {
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_system_orders_trade ON system_orders(trade_id);
CREATE TABLE IF NOT EXISTS trend_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coin TEXT NOT NULL,
prev_state TEXT NOT NULL,
new_state TEXT NOT NULL,
direction TEXT NOT NULL,
z_score REAL,
volatility REAL,
bg_change REAL,
hl_change REAL,
bn_change REAL,
okx_change REAL,
ex_agree INTEGER,
ex_total INTEGER,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trend_events_coin ON trend_events(coin);
CREATE INDEX IF NOT EXISTS idx_trend_events_created ON trend_events(created_at);
CREATE TABLE IF NOT EXISTS cm_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coin TEXT NOT NULL,
prev_state TEXT NOT NULL,
new_state TEXT NOT NULL,
direction TEXT NOT NULL,
score REAL,
avg_change REAL,
ex_agree INTEGER,
ex_total INTEGER,
bg_1m REAL,
hl_1m REAL,
bn_1m REAL,
okx_1m REAL,
bg_5m REAL,
hl_5m REAL,
bn_5m REAL,
okx_5m REAL,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cm_events_coin ON cm_events(coin);
CREATE INDEX IF NOT EXISTS idx_cm_events_created ON cm_events(created_at);
`
_, err := d.Exec(schema)
if err != nil {
+60
View File
@@ -0,0 +1,60 @@
package db
import (
"time"
)
// TrendEventRecord represents a persisted trend state transition.
type TrendEventRecord struct {
ID int64 `json:"id"`
Coin string `json:"coin"`
PrevState string `json:"prev_state"`
NewState string `json:"new_state"`
Direction string `json:"direction"`
ZScore float64 `json:"z_score"`
Volatility float64 `json:"volatility"`
BGChange float64 `json:"bg_change"`
HLChange float64 `json:"hl_change"`
BNChange float64 `json:"bn_change"`
OKXChange float64 `json:"okx_change"`
ExAgree int `json:"ex_agree"`
ExTotal int `json:"ex_total"`
CreatedAt string `json:"created_at"`
}
// InsertTrendEvent saves a trend event to the database.
func (d *DB) InsertTrendEvent(coin, prevState, newState, direction string, zScore, volatility, bgChange, hlChange, bnChange, okxChange float64, exAgree, exTotal int) error {
_, err := d.Exec(`
INSERT INTO trend_events (coin, prev_state, new_state, direction, z_score, volatility, bg_change, hl_change, bn_change, okx_change, ex_agree, ex_total, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
coin, prevState, newState, direction, zScore, volatility, bgChange, hlChange, bnChange, okxChange, exAgree, exTotal, Now().Format(time.RFC3339))
return err
}
// GetTrendEvents returns trend events ordered by creation time descending.
func (d *DB) GetTrendEvents(limit int) ([]TrendEventRecord, error) {
if limit <= 0 {
limit = 100
}
rows, err := d.Query(`
SELECT id, coin, prev_state, new_state, direction, z_score, volatility, bg_change, hl_change, bn_change, okx_change, ex_agree, ex_total, created_at
FROM trend_events
ORDER BY created_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []TrendEventRecord
for rows.Next() {
var r TrendEventRecord
if err := rows.Scan(&r.ID, &r.Coin, &r.PrevState, &r.NewState, &r.Direction,
&r.ZScore, &r.Volatility, &r.BGChange, &r.HLChange, &r.BNChange, &r.OKXChange,
&r.ExAgree, &r.ExTotal, &r.CreatedAt); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
+55 -17
View File
@@ -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
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
View File
@@ -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)]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.stats-row{display:flex;gap:16px;flex-wrap:wrap}.stat{display:flex;flex-direction:column;align-items:center;min-width:60px}.stat label{font-size:11px;color:var(--text-dim);margin-bottom:2px}.stat span{font-size:20px;font-weight:700}.pct-green{color:var(--green)}.pct-red{color:var(--red)}.pct-gray{color:var(--text-dim)}.pct-yellow{color:var(--yellow)}.pct-blue{color:var(--blue)}#conn-detail{font-size:11px;white-space:nowrap}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.trade-row{cursor:pointer}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}.stats-row{justify-content:center}}#bl-body{display:flex;gap:8px;flex-wrap:wrap}.bl-item{background:#f851491a;border:1px solid rgba(248,81,73,.3);border-radius:4px;padding:4px 10px;font-size:12px;color:var(--red);cursor:default}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}.modal-content{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;box-shadow:0 8px 32px #00000080}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;margin:0;padding:0;border:none;color:var(--text)}.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1}.modal-close:hover{background:#ffffff1a;color:var(--text)}#trade-detail-body{padding:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}.detail-section{padding:14px 20px;border-bottom:1px solid rgba(48,54,61,.4)}.detail-section:last-child{border-bottom:none}.detail-section-full{grid-column:1 / -1}.detail-section h3{font-size:12px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}.detail-row{display:flex;justify-content:space-between;padding:3px 0;font-size:13px}.detail-row .label{color:var(--text-dim)}.detail-row .value{font-weight:500}.detail-orders{width:100%;font-size:12px}.detail-orders th{background:var(--bg);font-size:10px}.detail-orders td{padding:4px 6px}
:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.stats-row{display:flex;gap:16px;flex-wrap:wrap}.stat{display:flex;flex-direction:column;align-items:center;min-width:60px}.stat label{font-size:11px;color:var(--text-dim);margin-bottom:2px}.stat span{font-size:20px;font-weight:700}.pct-green{color:var(--green)}.pct-red{color:var(--red)}.pct-gray{color:var(--text-dim)}.pct-yellow{color:var(--yellow)}.pct-blue{color:var(--blue)}#conn-detail{font-size:11px;white-space:nowrap}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.trade-row{cursor:pointer}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}.stats-row{justify-content:center}}#bl-body{display:flex;gap:8px;flex-wrap:wrap}.bl-item{background:#f851491a;border:1px solid rgba(248,81,73,.3);border-radius:4px;padding:4px 10px;font-size:12px;color:var(--red);cursor:default}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}.modal-content{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;box-shadow:0 8px 32px #00000080}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;margin:0;padding:0;border:none;color:var(--text)}.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1}.modal-close:hover{background:#ffffff1a;color:var(--text)}#trade-detail-body{padding:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}.detail-section{padding:14px 20px;border-bottom:1px solid rgba(48,54,61,.4)}.detail-section:last-child{border-bottom:none}.detail-section-full{grid-column:1 / -1}.detail-section h3{font-size:12px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}.detail-row{display:flex;justify-content:space-between;padding:3px 0;font-size:13px}.detail-row .label{color:var(--text-dim)}.detail-row .value{font-weight:500}.detail-orders{width:100%;font-size:12px}.detail-orders th{background:var(--bg);font-size:10px}.detail-orders td{padding:4px 6px}#momentum-card{grid-column:1 / -1}#momentum-table th{cursor:pointer;-webkit-user-select:none;user-select:none}#momentum-table th:hover{color:var(--accent)}#momentum-table td{font-variant-numeric:tabular-nums}#trend-card{grid-column:1 / -1}#trend-table th{-webkit-user-select:none;user-select:none}#trend-table td{font-variant-numeric:tabular-nums}.trend-state{font-weight:600;font-size:12px}.trend-alert{background:#d299220d}.trend-alert:hover td{background:#d299221a!important}.trend-confirmed{background:#3fb95014}.trend-confirmed:hover td{background:#3fb95026!important}.trend-exhausting{background:#8b949e0d}.trend-exhausting:hover td{background:#8b949e1a!important}
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Monitor Dashboard</title>
<script type="module" crossorigin src="/static/assets/index-D_JzXaOQ.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-Bh5bnFYE.css">
<script type="module" crossorigin src="/static/assets/index-Dr4jUtK1.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DsdSIpuQ.css">
</head>
<body>
<div id="root"></div>
+18
View File
@@ -224,3 +224,21 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
.detail-orders { width: 100%; font-size: 12px; }
.detail-orders th { background: var(--bg); font-size: 10px; }
.detail-orders td { padding: 4px 6px; }
/* Momentum Card */
#momentum-card { grid-column: 1 / -1; }
#momentum-table th { cursor: pointer; user-select: none; }
#momentum-table th:hover { color: var(--accent); }
#momentum-table td { font-variant-numeric: tabular-nums; }
/* Trend Card */
#trend-card { grid-column: 1 / -1; }
#trend-table th { user-select: none; }
#trend-table td { font-variant-numeric: tabular-nums; }
.trend-state { font-weight: 600; font-size: 12px; }
.trend-alert { background: rgba(210, 153, 34, 0.05); }
.trend-alert:hover td { background: rgba(210, 153, 34, 0.1) !important; }
.trend-confirmed { background: rgba(63, 185, 80, 0.08); }
.trend-confirmed:hover td { background: rgba(63, 185, 80, 0.15) !important; }
.trend-exhausting { background: rgba(139, 148, 158, 0.05); }
.trend-exhausting:hover td { background: rgba(139, 148, 158, 0.1) !important; }
+520 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const EXCHANGES = ['HyperLiquid', 'Bitget']
const EXCHANGES = ['HyperLiquid', 'Bitget', 'Binance', 'OKX']
function formatPrice(p) {
if (p == null || p <= 0) return '-'
@@ -26,6 +26,11 @@ export default function App() {
const [blacklist, setBlacklist] = useState([])
const [stats, setStats] = useState({})
const [trades, setTrades] = useState([])
const [momentum, setMomentum] = useState([])
const [trendData, setTrendData] = useState([])
const [trendHistory, setTrendHistory] = useState([])
const [cmData, setCmData] = useState([])
const [cmHistory, setCmHistory] = useState([])
const priceCacheRef = useRef({})
// Clock
@@ -69,6 +74,15 @@ export default function App() {
case 'blacklist':
setBlacklist(msg.data || [])
break
case 'momentum':
setMomentum(msg.data || [])
break
case 'trend':
setTrendData(msg.data || [])
break
case 'cumulative':
setCmData(msg.data || [])
break
case 'stats':
setStats(msg.data || {})
if (msg.data && msg.data.blacklist) {
@@ -104,6 +118,40 @@ export default function App() {
return () => clearInterval(id)
}, [loadTrades])
// Load trend history
const loadTrendHistory = useCallback(async () => {
try {
const resp = await fetch('/api/trend-history')
const data = await resp.json()
setTrendHistory(data.events || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadTrendHistory()
const id = setInterval(loadTrendHistory, 5000)
return () => clearInterval(id)
}, [loadTrendHistory])
// Load cumulative history
const loadCmHistory = useCallback(async () => {
try {
const resp = await fetch('/api/cm-history')
const data = await resp.json()
setCmHistory(data.events || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadCmHistory()
const id = setInterval(loadCmHistory, 5000)
return () => clearInterval(id)
}, [loadCmHistory])
// Handle prices
function handlePrices(data) {
if (!data || data.length === 0) return
@@ -162,6 +210,29 @@ export default function App() {
</header>
<div className="grid">
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* Momentum Scanner */}
<MomentumCard momentum={momentum} />
{/* Trend Detection */}
<TrendCard trend={trendData} />
{/* Trend History */}
<TrendHistoryCard history={trendHistory} />
{/* Cumulative Change (1min consensus) */}
<CmCard data={cmData} />
{/* Cumulative History */}
<CmHistoryCard history={cmHistory} />
{/* ---- 交易相关 ---- */}
{/* Stats Summary */}
<StatsCard stats={stats} />
@@ -171,12 +242,6 @@ export default function App() {
{/* PnL Growth Chart */}
<PnlChart />
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* Recent Trades */}
<TradesCard trades={trades} onRefresh={loadTrades} />
@@ -333,11 +398,11 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
<div className="table-wrap">
<table id="price-table">
<thead>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>毛价差</th><th>BGHL净利</th><th>HLBG净利</th></tr>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>Binance</th><th>OKX</th><th>毛价差</th><th>BGHL净利</th><th>HLBG净利</th></tr>
</thead>
<tbody id="price-body">
{coins.length === 0 ? (
<tr><td colSpan="6" className="loading">等待数据...</td></tr>
<tr><td colSpan="8" className="loading">等待数据...</td></tr>
) : coins.map(coin => {
const row = prices.find(p => p.coin === coin)
if (!row) {
@@ -770,3 +835,449 @@ function PnlChart() {
</section>
)
}
// ============ Momentum Scanner Card ============
function MomentumCard({ momentum }) {
const [sortCol, setSortCol] = useState('score')
const [sortDir, setSortDir] = useState('desc')
function toggleSort(col) {
if (sortCol === col) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortCol(col)
setSortDir('desc')
}
}
function sortArrow(col) {
if (sortCol !== col) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
const sorted = [...momentum].sort((a, b) => {
let va, vb
switch (sortCol) {
case 'coin': va = a.coin; vb = b.coin; break
case 'bg_1s': va = a.bg_1s || 0; vb = b.bg_1s || 0; break
case 'bg_5s': va = a.bg_5s || 0; vb = b.bg_5s || 0; break
case 'bg_15s': va = a.bg_15s || 0; vb = b.bg_15s || 0; break
case 'hl_1s': va = a.hl_1s || 0; vb = b.hl_1s || 0; break
case 'hl_5s': va = a.hl_5s || 0; vb = b.hl_5s || 0; break
case 'hl_15s': va = a.hl_15s || 0; vb = b.hl_15s || 0; break
case 'bn_1s': va = a.bn_1s || 0; vb = b.bn_1s || 0; break
case 'bn_5s': va = a.bn_5s || 0; vb = b.bn_5s || 0; break
case 'bn_15s': va = a.bn_15s || 0; vb = b.bn_15s || 0; break
case 'okx_1s': va = a.okx_1s || 0; vb = b.okx_1s || 0; break
case 'okx_5s': va = a.okx_5s || 0; vb = b.okx_5s || 0; break
case 'okx_15s': va = a.okx_15s || 0; vb = b.okx_15s || 0; break
default: va = a.score || 0; vb = b.score || 0
}
if (typeof va === 'string') {
return sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va)
}
return sortDir === 'asc' ? va - vb : vb - va
})
function dirIcon(dir) {
switch (dir) {
case 'up': return '\u2191'
case 'down': return '\u2193'
case 'flat': return '\u2192'
case 'mixed': return '\u2195'
default: return '-'
}
}
function dirClass(dir) {
switch (dir) {
case 'up': return 'text-green'
case 'down': return 'text-red'
case 'mixed': return 'text-yellow'
default: return ''
}
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="momentum-card">
<h2> 动量扫描 (价格变动%)</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="momentum-table">
<thead>
<tr>
<th onClick={() => toggleSort('coin')} style={{cursor:'pointer'}}>币种{sortArrow('coin')}</th>
<th onClick={() => toggleSort('score')} style={{cursor:'pointer'}}>分数{sortArrow('score')}</th>
<th>方向</th>
<th onClick={() => toggleSort('bg_1s')} style={{cursor:'pointer'}}>BG 1s{sortArrow('bg_1s')}</th>
<th onClick={() => toggleSort('bg_5s')} style={{cursor:'pointer'}}>BG 5s{sortArrow('bg_5s')}</th>
<th onClick={() => toggleSort('bg_15s')} style={{cursor:'pointer'}}>BG 15s{sortArrow('bg_15s')}</th>
<th onClick={() => toggleSort('hl_1s')} style={{cursor:'pointer'}}>HL 1s{sortArrow('hl_1s')}</th>
<th onClick={() => toggleSort('hl_5s')} style={{cursor:'pointer'}}>HL 5s{sortArrow('hl_5s')}</th>
<th onClick={() => toggleSort('hl_15s')} style={{cursor:'pointer'}}>HL 15s{sortArrow('hl_15s')}</th>
<th onClick={() => toggleSort('bn_1s')} style={{cursor:'pointer'}}>BN 1s{sortArrow('bn_1s')}</th>
<th onClick={() => toggleSort('bn_5s')} style={{cursor:'pointer'}}>BN 5s{sortArrow('bn_5s')}</th>
<th onClick={() => toggleSort('bn_15s')} style={{cursor:'pointer'}}>BN 15s{sortArrow('bn_15s')}</th>
<th onClick={() => toggleSort('okx_1s')} style={{cursor:'pointer'}}>OKX 1s{sortArrow('okx_1s')}</th>
<th onClick={() => toggleSort('okx_5s')} style={{cursor:'pointer'}}>OKX 5s{sortArrow('okx_5s')}</th>
<th onClick={() => toggleSort('okx_15s')} style={{cursor:'pointer'}}>OKX 15s{sortArrow('okx_15s')}</th>
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>
正在收集动量数据... (需要至少 15 秒数据)
</td></tr>
) : sorted.slice(0, 50).map(entry => (
<tr key={entry.coin}>
<td><strong>{entry.coin}</strong></td>
<td className="text-right" style={{fontWeight:700}}>{entry.score.toFixed(4)}%</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:18}}>{dirIcon(entry.direction)}</td>
<td className={'text-right ' + changeClass(entry.bg_1s)}>{entry.bg_1s != null ? entry.bg_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_5s)}>{entry.bg_5s != null ? entry.bg_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_15s)}>{entry.bg_15s != null ? entry.bg_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_1s)}>{entry.hl_1s != null ? entry.hl_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_5s)}>{entry.hl_5s != null ? entry.hl_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_15s)}>{entry.hl_15s != null ? entry.hl_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_1s)}>{entry.bn_1s != null ? entry.bn_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_5s)}>{entry.bn_5s != null ? entry.bn_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_15s)}>{entry.bn_15s != null ? entry.bn_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_1s)}>{entry.okx_1s != null ? entry.okx_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_5s)}>{entry.okx_5s != null ? entry.okx_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_15s)}>{entry.okx_15s != null ? entry.okx_15s.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ Trend Detection Card ============
function TrendCard({ trend }) {
function stateLabel(state) {
switch (state) {
case 'alert': return '⚠ 异动'
case 'confirmed': return '🚀 趋势'
case 'exhausting': return '🔄 衰减'
default: return state
}
}
function stateClass(state) {
switch (state) {
case 'alert': return 'trend-alert'
case 'confirmed': return 'trend-confirmed'
case 'exhausting': return 'trend-exhausting'
default: return ''
}
}
function dirIcon(dir) {
return dir === 'up' ? '\u2191' : '\u2193'
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="trend-card">
<h2>📈 趋势检测 (价格异动)</h2>
<div className="table-wrap" style={{ maxHeight: 300 }}>
<table id="trend-table">
<thead>
<tr>
<th>币种</th>
<th>状态</th>
<th>方向</th>
<th>异动分</th>
<th>波动率</th>
<th>一致数</th>
<th>BG 15s</th>
<th>HL 15s</th>
<th>BN 15s</th>
<th>OKX 15s</th>
<th>时长</th>
</tr>
</thead>
<tbody>
{trend.length === 0 ? (
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
等待检测数据... (需要至少 3 个交易所数据)
</td></tr>
) : trend.slice(0, 30).map(entry => (
<tr key={entry.coin} className={stateClass(entry.state)}>
<td><strong>{entry.coin}</strong></td>
<td className="trend-state">{stateLabel(entry.state)}</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:18}}>{dirIcon(entry.direction)}</td>
<td className="text-right" style={{fontWeight:700}}>{(entry.anomaly_score || 0).toFixed(1)}σ</td>
<td className="text-right">{(entry.volatility || 0).toFixed(4)}%</td>
<td className="text-right">{entry.ex_changes || 0}/4</td>
<td className={'text-right ' + changeClass(entry.bg_change)}>{entry.bg_change != null ? entry.bg_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_change)}>{entry.hl_change != null ? entry.hl_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_change)}>{entry.bn_change != null ? entry.bn_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_change)}>{entry.okx_change != null ? entry.okx_change.toFixed(3) + '%' : '-'}</td>
<td className="text-dim">{entry.duration || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ Trend History Card ============
// ============ Cumulative Change Card (1min consensus) ============
function CmCard({ data }) {
function stateLabel(state) {
switch (state) {
case 'rising': return '↑ 上涨'
case 'falling': return '↓ 下跌'
default: return ' 中性'
}
}
function stateClass(state) {
switch (state) {
case 'rising': return 'text-green'
case 'falling': return 'text-red'
default: return 'text-dim'
}
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="cm-card">
<h2>📊 累积变动 (1min 共识)</h2>
<div className="table-wrap" style={{ maxHeight: 300 }}>
<table id="cm-table">
<thead>
<tr>
<th>币种</th>
<th>状态</th>
<th>方向</th>
<th>分数</th>
<th>均值%</th>
<th>一致</th>
<th>BG 1m</th>
<th>HL 1m</th>
<th>BN 1m</th>
<th>OKX 1m</th>
<th>BG 5m</th>
<th>HL 5m</th>
<th>BN 5m</th>
<th>OKX 5m</th>
</tr>
</thead>
<tbody>
{!data || data.length === 0 ? (
<tr><td colSpan="14" className="text-dim" style={{textAlign:'center',padding:20}}>
等待累积数据... (需要至少 1 分钟数据)
</td></tr>
) : data.slice(0, 30).map(entry => (
<tr key={entry.coin} className={stateClass(entry.state)}>
<td><strong>{entry.coin}</strong></td>
<td>{stateLabel(entry.state)}</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:16}}>{entry.direction === 'up' ? '↑' : '↓'}</td>
<td className="text-right" style={{fontWeight:700}}>{(entry.score || 0).toFixed(2)}</td>
<td className="text-right">{(entry.avg_change || 0).toFixed(3)}%</td>
<td className="text-right">{entry.ex_agree || 0}/{entry.ex_total || 0}</td>
<td className={'text-right ' + changeClass(entry.bg_1m)}>{entry.bg_1m != null ? entry.bg_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_1m)}>{entry.hl_1m != null ? entry.hl_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_1m)}>{entry.bn_1m != null ? entry.bn_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_1m)}>{entry.okx_1m != null ? entry.okx_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_5m)}>{entry.bg_5m != null ? entry.bg_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_5m)}>{entry.hl_5m != null ? entry.hl_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_5m)}>{entry.bn_5m != null ? entry.bn_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_5m)}>{entry.okx_5m != null ? entry.okx_5m.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ Cumulative Change History Card ============
function CmHistoryCard({ history }) {
function stateLabel(state) {
switch (state) {
case 'rising': return '↑ 上涨'
case 'falling': return '↓ 下跌'
default: return ' 中性'
}
}
function stateClass(state) {
switch (state) {
case 'rising': return 'text-green'
case 'falling': return 'text-red'
default: return 'text-dim'
}
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="cm-history-card">
<h2>📋 累积变动事件记录</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="cm-history-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>转换</th>
<th>方向</th>
<th>分数</th>
<th>均值%</th>
<th>一致</th>
<th>BG 1m</th>
<th>HL 1m</th>
<th>BN 1m</th>
<th>OKX 1m</th>
<th>BG 5m</th>
<th>HL 5m</th>
<th>BN 5m</th>
<th>OKX 5m</th>
</tr>
</thead>
<tbody>
{history.length === 0 ? (
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>
暂无累积变动事件记录
</td></tr>
) : history.slice(0, 100).map((ev, i) => (
<tr key={(ev.id || i) + '-cm'}>
<td className="text-dim">{ev.created_at ? new Date(ev.created_at).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
<td><strong>{ev.coin}</strong></td>
<td className={stateClass(ev.new_state)}>{ev.prev_state} {stateLabel(ev.new_state)}</td>
<td className={dirClass(ev.direction)} style={{textAlign:'center',fontSize:16}}>{ev.direction === 'up' ? '↑' : '↓'}</td>
<td className="text-right">{(ev.score || 0).toFixed(2)}</td>
<td className="text-right">{(ev.avg_change || 0).toFixed(3)}%</td>
<td className="text-right">{ev.ex_agree || 0}/{ev.ex_total || 0}</td>
<td className={'text-right ' + changeClass(ev.bg_1m)}>{ev.bg_1m != null ? ev.bg_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_1m)}>{ev.hl_1m != null ? ev.hl_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_1m)}>{ev.bn_1m != null ? ev.bn_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_1m)}>{ev.okx_1m != null ? ev.okx_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bg_5m)}>{ev.bg_5m != null ? ev.bg_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_5m)}>{ev.hl_5m != null ? ev.hl_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_5m)}>{ev.bn_5m != null ? ev.bn_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_5m)}>{ev.okx_5m != null ? ev.okx_5m.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
function TrendHistoryCard({ history }) {
function stateLabel(state) {
switch (state) {
case 'alert': return '⚠ 异动'
case 'confirmed': return '🚀 趋势'
case 'exhausting': return '🔄 衰减'
case 'idle': return '✓ 结束'
default: return state
}
}
function stateClass(state) {
switch (state) {
case 'alert': return 'text-yellow'
case 'confirmed': return 'text-green'
case 'exhausting': return 'text-dim'
case 'idle': return 'text-dim'
default: return ''
}
}
function dirIcon(dir) {
return dir === 'up' ? '\u2191' : '\u2193'
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="trend-history-card">
<h2>📋 趋势事件记录</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="trend-history-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>转换</th>
<th>方向</th>
<th>异动分</th>
<th>波动率</th>
<th>一致</th>
<th>BG</th>
<th>HL</th>
<th>BN</th>
<th>OKX</th>
</tr>
</thead>
<tbody>
{history.length === 0 ? (
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
暂无趋势事件记录
</td></tr>
) : history.slice(0, 100).map((ev, i) => (
<tr key={(ev.timestamp || ev.id || i) + '-' + i}>
<td className="text-dim">{ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : (ev.created_at ? new Date(ev.created_at).toLocaleTimeString('zh-CN', { hour12: false }) : '-')}</td>
<td><strong>{ev.coin}</strong></td>
<td className={stateClass(ev.new_state)}>{ev.prev_state} {stateLabel(ev.new_state)}</td>
<td className={dirClass(ev.direction)} style={{textAlign:'center',fontSize:16}}>{dirIcon(ev.direction)}</td>
<td className="text-right">{(ev.z_score || 0).toFixed(1)}σ</td>
<td className="text-right">{(ev.volatility || 0).toFixed(4)}%</td>
<td className="text-right">{ev.ex_agree || 0}/{ev.ex_total || 0}</td>
<td className={'text-right ' + changeClass(ev.bg_change)}>{ev.bg_change != null ? ev.bg_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_change)}>{ev.hl_change != null ? ev.hl_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_change)}>{ev.bn_change != null ? ev.bn_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_change)}>{ev.okx_change != null ? ev.okx_change.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
+52 -5
View File
@@ -60,6 +60,21 @@ func main() {
store := NewPriceStore()
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
// Initialize momentum tracker (for momentum scanning mode)
momentumTracker := NewMomentumTracker()
// Initialize trend detector (for price anomaly / trend detection)
trendDetector := NewTrendDetector(momentumTracker)
if cfg.TrendEnabled {
trendDetector.Configure(cfg.TrendBaselineWindow, cfg.TrendAnomalyMul, cfg.TrendConfirmTicks, cfg.TrendAlertCooldown)
log.Printf("[Trend] Z-score detection enabled (z-score >= %.1fσ, window=%d ticks, confirm=%d ticks)",
cfg.TrendAnomalyMul, cfg.TrendBaselineWindow, cfg.TrendConfirmTicks)
}
// Initialize cumulative tracker (1min/5min multi-exchange consensus change)
cumulativeTracker := NewCumulativeTracker()
log.Printf("[CM] Cumulative change tracking enabled (1m >= %.1f%%, 3+ exchanges)", cumulativeTracker.surgePct1m)
// Initialize SQLite database
database, err := db.Open("")
if err != nil {
@@ -75,7 +90,7 @@ func main() {
trader.startIPCServer()
// Initialize dashboard (web server + SSE)
dashboard := NewDashboard(store, trader, database, ":8888")
dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker)
go dashboard.Run()
// Spread window tracker — measures how long spreads stay above threshold
@@ -83,7 +98,9 @@ func main() {
// P3-4: wire real-time trade event broadcast
trader.OnTradeEvent = dashboard.BroadcastEvent
if trader.IsConfigured() {
if cfg.MomentumEnabled {
log.Printf("[Trader] MOMENTUM SCAN mode: arbitrage trading disabled, momentum detection active (threshold >= %.2f%%)", cfg.MomentumThresholdPct)
} else if trader.IsConfigured() {
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/leg, max %d positions, $%.0f capital)",
trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital)
if cfg.TestMode {
@@ -101,8 +118,8 @@ func main() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
// Collect symbols — only BG and HL for now (BN, dYdX disabled)
var bgSymbols, hlSymbols []string
// Collect symbols for all exchanges
var bgSymbols, hlSymbols, bnSymbols, okxSymbols []string
for _, c := range TrackedCoins {
if c.BG != "" {
bgSymbols = append(bgSymbols, c.BG)
@@ -110,9 +127,15 @@ func main() {
if c.HL != "" {
hlSymbols = append(hlSymbols, c.HL)
}
if c.BN != "" {
bnSymbols = append(bnSymbols, c.BN)
}
if c.OK != "" {
okxSymbols = append(okxSymbols, c.OK)
}
}
// Start exchange WS connections (BG + HL only)
// Start exchange WS connections
startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
go func() {
for {
@@ -133,6 +156,8 @@ func main() {
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).Run)
startExchange("Binance", exchange.NewBinanceWS(bnSymbols).Run)
startExchange("OKX", exchange.NewOKXWS(okxSymbols).Run)
log.Println("[Monitor] Waiting for initial data...")
time.Sleep(10 * time.Second)
@@ -206,6 +231,25 @@ func main() {
// Scan for arbitrage entries using maker fees (limit orders)
snap := store.GetAll()
// Feed prices to momentum tracker (for momentum scanning or trend detection)
if cfg.MomentumEnabled || cfg.TrendEnabled {
for coin, exMap := range snap {
for ex, price := range exMap {
momentumTracker.Record(coin, ex, price)
}
}
}
// Feed snapshots to cumulative tracker (always on)
for _, tc := range TrackedCoins {
exMap := snap[tc.Name]
if exMap == nil || len(exMap) < 3 {
continue
}
cumulativeTracker.Record(tc.Name, exMap)
}
makerOpps := ScanBGHL(snap)
dashboard.UpdateScan(makerOpps)
t2 := time.Now()
@@ -213,6 +257,8 @@ func main() {
// Track spread window durations (how long each opportunity stays alive)
spreadTracker.Tick(snap, cfg.TradeThreshold)
// In momentum mode, arbitrage trading is disabled
if !cfg.MomentumEnabled {
for _, opp := range makerOpps {
if opp.NetProfit < cfg.ArbThreshold {
continue
@@ -221,6 +267,7 @@ func main() {
log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit)
}
}
}
t3 := time.Now()
// Profile: warn if any step is slow
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"math"
"sort"
"sync"
)
// momentumWindow defines a time window for momentum calculation.
// At ~50ms per tick, N ticks ≈ N * 50ms.
type momentumWindow struct {
Name string // JSON key: "t1s", "t5s", "t15s"
Ticks int // how many ticks to look back
Label string // human-readable: "1s", "5s", "15s"
}
var momentumWindows = []momentumWindow{
{"t1s", 20, "1s"},
{"t5s", 100, "5s"},
{"t15s", 300, "15s"},
}
const maxMomentumRecords = 600
// momentumBuffer is a fixed-size ring buffer of prices for one coin+exchange.
type momentumBuffer struct {
prices [maxMomentumRecords]float64
head int // next write index
count int // total records written (capped at maxMomentumRecords)
}
// MomentumEntry is one coin's momentum data sent via SSE.
type MomentumEntry struct {
Coin string `json:"coin"`
BG1s float64 `json:"bg_1s"`
BG5s float64 `json:"bg_5s"`
BG15s float64 `json:"bg_15s"`
HL1s float64 `json:"hl_1s"`
HL5s float64 `json:"hl_5s"`
HL15s float64 `json:"hl_15s"`
BN1s float64 `json:"bn_1s"`
BN5s float64 `json:"bn_5s"`
BN15s float64 `json:"bn_15s"`
OKX1s float64 `json:"okx_1s"`
OKX5s float64 `json:"okx_5s"`
OKX15s float64 `json:"okx_15s"`
Score float64 `json:"score"` // max abs change across all windows
Direction string `json:"direction"` // "up", "down", "flat", "mixed"
}
// MomentumTracker tracks price momentum across all coins and exchanges.
type MomentumTracker struct {
mu sync.RWMutex
buffers map[string]map[string]*momentumBuffer // coin -> exchange -> buffer
}
func NewMomentumTracker() *MomentumTracker {
return &MomentumTracker{
buffers: make(map[string]map[string]*momentumBuffer),
}
}
// Record adds a price point for a coin+exchange.
func (mt *MomentumTracker) Record(coin, exchange string, price float64) {
mt.mu.Lock()
defer mt.mu.Unlock()
if mt.buffers[coin] == nil {
mt.buffers[coin] = make(map[string]*momentumBuffer)
}
buf, ok := mt.buffers[coin][exchange]
if !ok {
buf = &momentumBuffer{}
mt.buffers[coin][exchange] = buf
}
buf.prices[buf.head] = price
buf.head = (buf.head + 1) % maxMomentumRecords
if buf.count < maxMomentumRecords {
buf.count++
}
}
// Snapshot returns all coins with momentum data, sorted by score descending.
// Only includes coins where at least one window has non-zero change.
// Limited to maxResults entries.
func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
mt.mu.RLock()
defer mt.mu.RUnlock()
var result []MomentumEntry
for coin, exMap := range mt.buffers {
bgBuf, hasBG := exMap[ExBitget]
hlBuf, hasHL := exMap[ExHyperLiquid]
bnBuf, hasBN := exMap[ExBinance]
okBuf, hasOK := exMap[ExOKX]
if !hasBG && !hasHL && !hasBN && !hasOK {
continue
}
entry := MomentumEntry{Coin: coin}
var allChanges []float64
if hasBG {
changes := calcWindows(bgBuf)
entry.BG1s = changes[0]
entry.BG5s = changes[1]
entry.BG15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
if hasHL {
changes := calcWindows(hlBuf)
entry.HL1s = changes[0]
entry.HL5s = changes[1]
entry.HL15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
if hasBN {
changes := calcWindows(bnBuf)
entry.BN1s = changes[0]
entry.BN5s = changes[1]
entry.BN15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
if hasOK {
changes := calcWindows(okBuf)
entry.OKX1s = changes[0]
entry.OKX5s = changes[1]
entry.OKX15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
// Score: max absolute change across all windows
var maxAbs float64
for _, c := range allChanges {
abs := math.Abs(c)
if abs > maxAbs {
maxAbs = abs
}
}
entry.Score = math.Round(maxAbs*10000) / 10000
// Direction: majority vote across all windows
if maxAbs > 0.001 {
posCount := 0
negCount := 0
for _, c := range allChanges {
if c > 0.001 {
posCount++
} else if c < -0.001 {
negCount++
}
}
total := posCount + negCount
if total == 0 {
entry.Direction = "flat"
} else if float64(posCount)/float64(total) >= 0.66 {
entry.Direction = "up"
} else if float64(negCount)/float64(total) >= 0.66 {
entry.Direction = "down"
} else {
entry.Direction = "mixed"
}
} else {
entry.Direction = "flat"
}
result = append(result, entry)
}
// Sort by score descending
sort.Slice(result, func(i, j int) bool {
return result[i].Score > result[j].Score
})
if len(result) > 200 {
result = result[:200]
}
return result
}
// calcWindows computes change% for all 3 windows: (current - old) / old * 100.
// Returns 0 for windows that don't have enough data yet.
func calcWindows(buf *momentumBuffer) [3]float64 {
var result [3]float64
for i, w := range momentumWindows {
if buf.count < w.Ticks+1 {
continue
}
currentIdx := (buf.head - 1 + maxMomentumRecords) % maxMomentumRecords
oldIdx := (currentIdx - w.Ticks + maxMomentumRecords) % maxMomentumRecords
current := buf.prices[currentIdx]
old := buf.prices[oldIdx]
if old > 0 {
result[i] = math.Round((current-old)/old*10000) / 10000
}
}
return result
}
+178 -176
View File
@@ -4,10 +4,12 @@ import (
"sort"
)
// Exchange names — only Bitget and HyperLiquid are trading exchanges
// Exchange names — Bitget and HyperLiquid are trading exchanges; Binance and OKX are for momentum/display
const (
ExHyperLiquid = "HyperLiquid"
ExBitget = "Bitget"
ExBinance = "Binance"
ExOKX = "OKX"
)
// Taker fee rates (%) — for IOC market orders on trading exchanges
@@ -17,181 +19,181 @@ var takerFees = map[string]float64{
}
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"},
{Name: "0G", BN: "", BG: "0GUSDT", HL: "0G"},
{Name: "2Z", BN: "", BG: "2ZUSDT", HL: "2Z"},
{Name: "AAVE", BN: "", BG: "AAVEUSDT", HL: "AAVE"},
{Name: "ACE", BN: "", BG: "ACEUSDT", HL: "ACE"},
{Name: "ADA", BN: "", BG: "ADAUSDT", HL: "ADA"},
{Name: "AIXBT", BN: "", BG: "AIXBTUSDT", HL: "AIXBT"},
{Name: "ALGO", BN: "", BG: "ALGOUSDT", HL: "ALGO"},
{Name: "ALT", BN: "", BG: "ALTUSDT", HL: "ALT"},
{Name: "ANIME", BN: "", BG: "ANIMEUSDT", HL: "ANIME"},
{Name: "APE", BN: "", BG: "APEUSDT", HL: "APE"},
{Name: "APT", BN: "", BG: "APTUSDT", HL: "APT"},
{Name: "AR", BN: "", BG: "ARUSDT", HL: "AR"},
{Name: "ARK", BN: "", BG: "ARKUSDT", HL: "ARK"},
{Name: "ASTER", BN: "", BG: "ASTERUSDT", HL: "ASTER"},
{Name: "ATOM", BN: "", BG: "ATOMUSDT", HL: "ATOM"},
{Name: "AVAX", BN: "", BG: "AVAXUSDT", HL: "AVAX"},
{Name: "AVNT", BN: "", BG: "AVNTUSDT", HL: "AVNT"},
{Name: "AXS", BN: "", BG: "AXSUSDT", HL: "AXS"},
{Name: "AZTEC", BN: "", BG: "AZTECUSDT", HL: "AZTEC"},
{Name: "BABY", BN: "", BG: "BABYUSDT", HL: "BABY"},
{Name: "BANANA", BN: "", BG: "BANANAUSDT", HL: "BANANA"},
{Name: "BCH", BN: "", BG: "BCHUSDT", HL: "BCH"},
{Name: "BERA", BN: "", BG: "BERAUSDT", HL: "BERA"},
{Name: "BIGTIME", BN: "", BG: "BIGTIMEUSDT", HL: "BIGTIME"},
{Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"},
{Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"},
{Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"},
{Name: "BNT", BN: "", BG: "BNTUSDT", HL: ""},
{Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"},
{Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"},
{Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"},
{Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"},
{Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"},
{Name: "CATI", BN: "", BG: "CATIUSDT", HL: ""},
{Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"},
{Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"},
{Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"},
{Name: "CHILLGUY", BN: "", BG: "CHILLGUYUSDT", HL: "CHILLGUY"},
{Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"},
{Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"},
{Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"},
{Name: "CYBER", BN: "", BG: "CYBERUSDT", HL: ""},
{Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"},
{Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"},
{Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"},
{Name: "DYDX", BN: "", BG: "DYDXUSDT", HL: "DYDX"},
{Name: "DYM", BN: "", BG: "DYMUSDT", HL: "DYM"},
{Name: "EIGEN", BN: "", BG: "EIGENUSDT", HL: "EIGEN"},
{Name: "ENA", BN: "", BG: "ENAUSDT", HL: "ENA"},
{Name: "ENS", BN: "", BG: "ENSUSDT", HL: "ENS"},
{Name: "ETC", BN: "", BG: "ETCUSDT", HL: "ETC"},
{Name: "ETH", BN: "", BG: "ETHUSDT", HL: "ETH"},
{Name: "ETHFI", BN: "", BG: "ETHFIUSDT", HL: "ETHFI"},
{Name: "FARTCOIN", BN: "", BG: "FARTCOINUSDT", HL: "FARTCOIN"},
{Name: "FET", BN: "", BG: "FETUSDT", HL: "FET"},
{Name: "FIL", BN: "", BG: "FILUSDT", HL: "FIL"},
{Name: "FOGO", BN: "", BG: "FOGOUSDT", HL: "FOGO"},
{Name: "GALA", BN: "", BG: "GALAUSDT", HL: "GALA"},
{Name: "GAS", BN: "", BG: "GASUSDT", HL: "GAS"},
{Name: "GMT", BN: "", BG: "GMTUSDT", HL: "GMT"},
{Name: "GMX", BN: "", BG: "GMXUSDT", HL: "GMX"},
{Name: "GOAT", BN: "", BG: "GOATUSDT", HL: "GOAT"},
{Name: "GRASS", BN: "", BG: "GRASSUSDT", HL: "GRASS"},
{Name: "GRIFFAIN", BN: "", BG: "GRIFFAINUSDT", HL: "GRIFFAIN"},
{Name: "HBAR", BN: "", BG: "HBARUSDT", HL: "HBAR"},
{Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"},
{Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"},
{Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"},
{Name: "ILV", BN: "", BG: "ILVUSDT", HL: ""},
{Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"},
{Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"},
{Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"},
{Name: "IO", BN: "", BG: "IOUSDT", HL: "IO"},
{Name: "IOTA", BN: "", BG: "IOTAUSDT", HL: "IOTA"},
{Name: "IP", BN: "", BG: "IPUSDT", HL: "IP"},
{Name: "JTO", BN: "", BG: "JTOUSDT", HL: "JTO"},
{Name: "JUP", BN: "", BG: "JUPUSDT", HL: "JUP"},
{Name: "KAITO", BN: "", BG: "KAITOUSDT", HL: "KAITO"},
{Name: "KAS", BN: "", BG: "KASUSDT", HL: "KAS"},
{Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"},
{Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"},
{Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"},
{Name: "LISTA", BN: "", BG: "LISTAUSDT", HL: ""},
{Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"},
{Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"},
{Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"},
{Name: "MAV", BN: "", BG: "MAVUSDT", HL: "MAV"},
{Name: "ME", BN: "", BG: "MEUSDT", HL: "ME"},
{Name: "MEGA", BN: "", BG: "MEGAUSDT", HL: "MEGA"},
{Name: "MELANIA", BN: "", BG: "MELANIAUSDT", HL: "MELANIA"},
{Name: "MEME", BN: "", BG: "MEMEUSDT", HL: "MEME"},
{Name: "MERL", BN: "", BG: "MERLUSDT", HL: "MERL"},
{Name: "MET", BN: "", BG: "METUSDT", HL: "MET"},
{Name: "MINA", BN: "", BG: "MINAUSDT", HL: "MINA"},
{Name: "MON", BN: "", BG: "MONUSDT", HL: "MON"},
{Name: "MOODENG", BN: "", BG: "MOODENGUSDT", HL: "MOODENG"},
{Name: "MORPHO", BN: "", BG: "MORPHOUSDT", HL: "MORPHO"},
{Name: "MOVE", BN: "", BG: "MOVEUSDT", HL: "MOVE"},
{Name: "NEAR", BN: "", BG: "NEARUSDT", HL: "NEAR"},
{Name: "NEO", BN: "", BG: "NEOUSDT", HL: "NEO"},
{Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"},
{Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"},
{Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"},
{Name: "OGN", BN: "", BG: "OGNUSDT", HL: ""},
{Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"},
{Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"},
{Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"},
{Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"},
{Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"},
{Name: "PIXEL", BN: "", BG: "PIXELUSDT", HL: ""},
{Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"},
{Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"},
{Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"},
{Name: "POPCAT", BN: "", BG: "POPCATUSDT", HL: "POPCAT"},
{Name: "PROVE", BN: "", BG: "PROVEUSDT", HL: "PROVE"},
{Name: "PUMP", BN: "", BG: "PUMPUSDT", HL: "PUMP"},
{Name: "PYTH", BN: "", BG: "PYTHUSDT", HL: "PYTH"},
{Name: "RENDER", BN: "", BG: "RENDERUSDT", HL: "RENDER"},
{Name: "RESOLV", BN: "", BG: "RESOLVUSDT", HL: "RESOLV"},
{Name: "REZ", BN: "", BG: "REZUSDT", HL: "REZ"},
{Name: "RSR", BN: "", BG: "RSRUSDT", HL: "RSR"},
{Name: "RUNE", BN: "", BG: "RUNEUSDT", HL: "RUNE"},
{Name: "S", BN: "", BG: "SUSDT", HL: "S"},
{Name: "SAGA", BN: "", BG: "SAGAUSDT", HL: "SAGA"},
{Name: "SAND", BN: "", BG: "SANDUSDT", HL: "SAND"},
{Name: "SEI", BN: "", BG: "SEIUSDT", HL: "SEI"},
{Name: "SKR", BN: "", BG: "SKRUSDT", HL: "SKR"},
{Name: "SKY", BN: "", BG: "SKYUSDT", HL: "SKY"},
{Name: "SNX", BN: "", BG: "SNXUSDT", HL: "SNX"},
{Name: "SOL", BN: "", BG: "SOLUSDT", HL: "SOL"},
{Name: "SOPH", BN: "", BG: "SOPHUSDT", HL: "SOPH"},
{Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"},
{Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"},
{Name: "STG", BN: "", BG: "STGUSDT", HL: ""},
{Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"},
{Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"},
{Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"},
{Name: "SUPER", BN: "", BG: "SUPERUSDT", HL: "SUPER"},
{Name: "SUSHI", BN: "", BG: "SUSHIUSDT", HL: "SUSHI"},
{Name: "SYRUP", BN: "", BG: "SYRUPUSDT", HL: "SYRUP"},
{Name: "TAO", BN: "", BG: "TAOUSDT", HL: "TAO"},
{Name: "TIA", BN: "", BG: "TIAUSDT", HL: "TIA"},
{Name: "TNSR", BN: "", BG: "TNSRUSDT", HL: "TNSR"},
{Name: "TON", BN: "", BG: "TONUSDT", HL: "TON"},
{Name: "TRB", BN: "", BG: "TRBUSDT", HL: "TRB"},
{Name: "TRUMP", BN: "", BG: "TRUMPUSDT", HL: "TRUMP"},
{Name: "TRX", BN: "", BG: "TRXUSDT", HL: "TRX"},
{Name: "TURBO", BN: "", BG: "TURBOUSDT", HL: "TURBO"},
{Name: "UMA", BN: "", BG: "UMAUSDT", HL: "UMA"},
{Name: "UNI", BN: "", BG: "UNIUSDT", HL: "UNI"},
{Name: "USUAL", BN: "", BG: "USUALUSDT", HL: "USUAL"},
{Name: "VIRTUAL", BN: "", BG: "VIRTUALUSDT", HL: "VIRTUAL"},
{Name: "VVV", BN: "", BG: "VVVUSDT", HL: "VVV"},
{Name: "W", BN: "", BG: "WUSDT", HL: "W"},
{Name: "WCT", BN: "", BG: "WCTUSDT", HL: "WCT"},
{Name: "WLD", BN: "", BG: "WLDUSDT", HL: "WLD"},
{Name: "WLFI", BN: "", BG: "WLFIUSDT", HL: "WLFI"},
{Name: "XAI", BN: "", BG: "XAIUSDT", HL: "XAI"},
{Name: "XLM", BN: "", BG: "XLMUSDT", HL: "XLM"},
{Name: "XMR", BN: "", BG: "XMRUSDT", HL: "XMR"},
{Name: "XPL", BN: "", BG: "XPLUSDT", HL: "XPL"},
{Name: "XRP", BN: "", BG: "XRPUSDT", HL: "XRP"},
{Name: "YGG", BN: "", BG: "YGGUSDT", HL: "YGG"},
{Name: "ZEC", BN: "", BG: "ZECUSDT", HL: "ZEC"},
{Name: "ZEN", BN: "", BG: "ZENUSDT", HL: "ZEN"},
{Name: "ZETA", BN: "", BG: "ZETAUSDT", HL: "ZETA"},
{Name: "ZK", BN: "", BG: "ZKUSDT", HL: "ZK"},
{Name: "ZORA", BN: "", BG: "ZORAUSDT", HL: "ZORA"},
{Name: "ZRO", BN: "", BG: "ZROUSDT", HL: "ZRO"},
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE", OK: "DOGE-USDT-SWAP"},
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK", OK: "LINK-USDT-SWAP"},
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO", OK: "ONDO-USDT-SWAP"},
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP", OK: "OP-USDT-SWAP"},
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF", OK: "WIF-USDT-SWAP"},
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB", OK: "ARB-USDT-SWAP"},
{Name: "0G", BN: "0GUSDT", BG: "0GUSDT", HL: "0G", OK: "0G-USDT-SWAP"},
{Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", HL: "2Z", OK: "2Z-USDT-SWAP"},
{Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", HL: "AAVE", OK: "AAVE-USDT-SWAP"},
{Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", HL: "ACE", OK: "ACE-USDT-SWAP"},
{Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", HL: "ADA", OK: "ADA-USDT-SWAP"},
{Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", HL: "AIXBT", OK: "AIXBT-USDT-SWAP"},
{Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", HL: "ALGO", OK: "ALGO-USDT-SWAP"},
{Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", HL: "ALT", OK: "ALT-USDT-SWAP"},
{Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", HL: "ANIME", OK: "ANIME-USDT-SWAP"},
{Name: "APE", BN: "APEUSDT", BG: "APEUSDT", HL: "APE", OK: "APE-USDT-SWAP"},
{Name: "APT", BN: "APTUSDT", BG: "APTUSDT", HL: "APT", OK: "APT-USDT-SWAP"},
{Name: "AR", BN: "ARUSDT", BG: "ARUSDT", HL: "AR", OK: "AR-USDT-SWAP"},
{Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", HL: "ARK", OK: "ARK-USDT-SWAP"},
{Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", HL: "ASTER", OK: "ASTER-USDT-SWAP"},
{Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", HL: "ATOM", OK: "ATOM-USDT-SWAP"},
{Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", HL: "AVAX", OK: "AVAX-USDT-SWAP"},
{Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", HL: "AVNT", OK: "AVNT-USDT-SWAP"},
{Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", HL: "AXS", OK: "AXS-USDT-SWAP"},
{Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", HL: "AZTEC", OK: "AZTEC-USDT-SWAP"},
{Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", HL: "BABY", OK: "BABY-USDT-SWAP"},
{Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", HL: "BANANA", OK: "BANANA-USDT-SWAP"},
{Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", HL: "BCH", OK: "BCH-USDT-SWAP"},
{Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", HL: "BERA", OK: "BERA-USDT-SWAP"},
{Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", HL: "BIGTIME", OK: "BIGTIME-USDT-SWAP"},
{Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", HL: "BIO", OK: "BIO-USDT-SWAP"},
{Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", HL: "BLUR", OK: "BLUR-USDT-SWAP"},
{Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", HL: "BNB", OK: "BNB-USDT-SWAP"},
{Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", HL: "", OK: "BNT-USDT-SWAP"},
{Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", HL: "BOME", OK: "BOME-USDT-SWAP"},
{Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", HL: "BRETT", OK: "BRETT-USDT-SWAP"},
{Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", HL: "BSV", OK: "BSV-USDT-SWAP"},
{Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", HL: "BTC", OK: "BTC-USDT-SWAP"},
{Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", HL: "CAKE", OK: "CAKE-USDT-SWAP"},
{Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", HL: "", OK: "CATI-USDT-SWAP"},
{Name: "CC", BN: "CCUSDT", BG: "CCUSDT", HL: "CC", OK: "CC-USDT-SWAP"},
{Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", HL: "CELO", OK: "CELO-USDT-SWAP"},
{Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", HL: "CFX", OK: "CFX-USDT-SWAP"},
{Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", HL: "CHILLGUY", OK: "CHILLGUY-USDT-SWAP"},
{Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", HL: "CHIP", OK: "CHIP-USDT-SWAP"},
{Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", HL: "COMP", OK: "COMP-USDT-SWAP"},
{Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", HL: "CRV", OK: "CRV-USDT-SWAP"},
{Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", HL: "", OK: "CYBER-USDT-SWAP"},
{Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", HL: "DASH", OK: "DASH-USDT-SWAP"},
{Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", HL: "DOOD", OK: "DOOD-USDT-SWAP"},
{Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", HL: "DOT", OK: "DOT-USDT-SWAP"},
{Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", HL: "DYDX", OK: "DYDX-USDT-SWAP"},
{Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", HL: "DYM", OK: "DYM-USDT-SWAP"},
{Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", HL: "EIGEN", OK: "EIGEN-USDT-SWAP"},
{Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", HL: "ENA", OK: "ENA-USDT-SWAP"},
{Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", HL: "ENS", OK: "ENS-USDT-SWAP"},
{Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", HL: "ETC", OK: "ETC-USDT-SWAP"},
{Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", HL: "ETH", OK: "ETH-USDT-SWAP"},
{Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", HL: "ETHFI", OK: "ETHFI-USDT-SWAP"},
{Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", HL: "FARTCOIN", OK: "FARTCOIN-USDT-SWAP"},
{Name: "FET", BN: "FETUSDT", BG: "FETUSDT", HL: "FET", OK: "FET-USDT-SWAP"},
{Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", HL: "FIL", OK: "FIL-USDT-SWAP"},
{Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", HL: "FOGO", OK: "FOGO-USDT-SWAP"},
{Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", HL: "GALA", OK: "GALA-USDT-SWAP"},
{Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", HL: "GAS", OK: "GAS-USDT-SWAP"},
{Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", HL: "GMT", OK: "GMT-USDT-SWAP"},
{Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", HL: "GMX", OK: "GMX-USDT-SWAP"},
{Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", HL: "GOAT", OK: "GOAT-USDT-SWAP"},
{Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", HL: "GRASS", OK: "GRASS-USDT-SWAP"},
{Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", HL: "GRIFFAIN", OK: "GRIFFAIN-USDT-SWAP"},
{Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", HL: "HBAR", OK: "HBAR-USDT-SWAP"},
{Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", HL: "HYPE", OK: "HYPE-USDT-SWAP"},
{Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", HL: "HYPER", OK: "HYPER-USDT-SWAP"},
{Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", HL: "ICP", OK: "ICP-USDT-SWAP"},
{Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", HL: "", OK: "ILV-USDT-SWAP"},
{Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", HL: "IMX", OK: "IMX-USDT-SWAP"},
{Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", HL: "INIT", OK: "INIT-USDT-SWAP"},
{Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", HL: "INJ", OK: "INJ-USDT-SWAP"},
{Name: "IO", BN: "IOUSDT", BG: "IOUSDT", HL: "IO", OK: "IO-USDT-SWAP"},
{Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", HL: "IOTA", OK: "IOTA-USDT-SWAP"},
{Name: "IP", BN: "IPUSDT", BG: "IPUSDT", HL: "IP", OK: "IP-USDT-SWAP"},
{Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", HL: "JTO", OK: "JTO-USDT-SWAP"},
{Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", HL: "JUP", OK: "JUP-USDT-SWAP"},
{Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", HL: "KAITO", OK: "KAITO-USDT-SWAP"},
{Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", HL: "KAS", OK: "KAS-USDT-SWAP"},
{Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", HL: "LAYER", OK: "LAYER-USDT-SWAP"},
{Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", HL: "LDO", OK: "LDO-USDT-SWAP"},
{Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", HL: "LINEA", OK: "LINEA-USDT-SWAP"},
{Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", HL: "", OK: "LISTA-USDT-SWAP"},
{Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", HL: "LIT", OK: "LIT-USDT-SWAP"},
{Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", HL: "LTC", OK: "LTC-USDT-SWAP"},
{Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", HL: "MANTA", OK: "MANTA-USDT-SWAP"},
{Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", HL: "MAV", OK: "MAV-USDT-SWAP"},
{Name: "ME", BN: "MEUSDT", BG: "MEUSDT", HL: "ME", OK: "ME-USDT-SWAP"},
{Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", HL: "MEGA", OK: "MEGA-USDT-SWAP"},
{Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", HL: "MELANIA", OK: "MELANIA-USDT-SWAP"},
{Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", HL: "MEME", OK: "MEME-USDT-SWAP"},
{Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", HL: "MERL", OK: "MERL-USDT-SWAP"},
{Name: "MET", BN: "METUSDT", BG: "METUSDT", HL: "MET", OK: "MET-USDT-SWAP"},
{Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", HL: "MINA", OK: "MINA-USDT-SWAP"},
{Name: "MON", BN: "MONUSDT", BG: "MONUSDT", HL: "MON", OK: "MON-USDT-SWAP"},
{Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", HL: "MOODENG", OK: "MOODENG-USDT-SWAP"},
{Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", HL: "MORPHO", OK: "MORPHO-USDT-SWAP"},
{Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", HL: "MOVE", OK: "MOVE-USDT-SWAP"},
{Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", HL: "NEAR", OK: "NEAR-USDT-SWAP"},
{Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", HL: "NEO", OK: "NEO-USDT-SWAP"},
{Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", HL: "NIL", OK: "NIL-USDT-SWAP"},
{Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", HL: "NOT", OK: "NOT-USDT-SWAP"},
{Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", HL: "NXPC", OK: "NXPC-USDT-SWAP"},
{Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", HL: "", OK: "OGN-USDT-SWAP"},
{Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", HL: "ORDI", OK: "ORDI-USDT-SWAP"},
{Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", HL: "PAXG", OK: "PAXG-USDT-SWAP"},
{Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", HL: "PENDLE", OK: "PENDLE-USDT-SWAP"},
{Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", HL: "PENGU", OK: "PENGU-USDT-SWAP"},
{Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", HL: "PEOPLE", OK: "PEOPLE-USDT-SWAP"},
{Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", HL: "", OK: "PIXEL-USDT-SWAP"},
{Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", HL: "PNUT", OK: "PNUT-USDT-SWAP"},
{Name: "POL", BN: "POLUSDT", BG: "POLUSDT", HL: "POL", OK: "POL-USDT-SWAP"},
{Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", HL: "POLYX", OK: "POLYX-USDT-SWAP"},
{Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", HL: "POPCAT", OK: "POPCAT-USDT-SWAP"},
{Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", HL: "PROVE", OK: "PROVE-USDT-SWAP"},
{Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", HL: "PUMP", OK: "PUMP-USDT-SWAP"},
{Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", HL: "PYTH", OK: "PYTH-USDT-SWAP"},
{Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", HL: "RENDER", OK: "RENDER-USDT-SWAP"},
{Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", HL: "RESOLV", OK: "RESOLV-USDT-SWAP"},
{Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", HL: "REZ", OK: "REZ-USDT-SWAP"},
{Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", HL: "RSR", OK: "RSR-USDT-SWAP"},
{Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", HL: "RUNE", OK: "RUNE-USDT-SWAP"},
{Name: "S", BN: "SUSDT", BG: "SUSDT", HL: "S", OK: "S-USDT-SWAP"},
{Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", HL: "SAGA", OK: "SAGA-USDT-SWAP"},
{Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", HL: "SAND", OK: "SAND-USDT-SWAP"},
{Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", HL: "SEI", OK: "SEI-USDT-SWAP"},
{Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", HL: "SKR", OK: "SKR-USDT-SWAP"},
{Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", HL: "SKY", OK: "SKY-USDT-SWAP"},
{Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", HL: "SNX", OK: "SNX-USDT-SWAP"},
{Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", HL: "SOL", OK: "SOL-USDT-SWAP"},
{Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", HL: "SOPH", OK: "SOPH-USDT-SWAP"},
{Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", HL: "SPX", OK: "SPX-USDT-SWAP"},
{Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", HL: "STABLE", OK: "STABLE-USDT-SWAP"},
{Name: "STG", BN: "STGUSDT", BG: "STGUSDT", HL: "", OK: "STG-USDT-SWAP"},
{Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", HL: "STRK", OK: "STRK-USDT-SWAP"},
{Name: "STX", BN: "STXUSDT", BG: "STXUSDT", HL: "STX", OK: "STX-USDT-SWAP"},
{Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", HL: "SUI", OK: "SUI-USDT-SWAP"},
{Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", HL: "SUPER", OK: "SUPER-USDT-SWAP"},
{Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", HL: "SUSHI", OK: "SUSHI-USDT-SWAP"},
{Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", HL: "SYRUP", OK: "SYRUP-USDT-SWAP"},
{Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", HL: "TAO", OK: "TAO-USDT-SWAP"},
{Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", HL: "TIA", OK: "TIA-USDT-SWAP"},
{Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", HL: "TNSR", OK: "TNSR-USDT-SWAP"},
{Name: "TON", BN: "TONUSDT", BG: "TONUSDT", HL: "TON", OK: "TON-USDT-SWAP"},
{Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", HL: "TRB", OK: "TRB-USDT-SWAP"},
{Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", HL: "TRUMP", OK: "TRUMP-USDT-SWAP"},
{Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", HL: "TRX", OK: "TRX-USDT-SWAP"},
{Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", HL: "TURBO", OK: "TURBO-USDT-SWAP"},
{Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", HL: "UMA", OK: "UMA-USDT-SWAP"},
{Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", HL: "UNI", OK: "UNI-USDT-SWAP"},
{Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", HL: "USUAL", OK: "USUAL-USDT-SWAP"},
{Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", HL: "VIRTUAL", OK: "VIRTUAL-USDT-SWAP"},
{Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", HL: "VVV", OK: "VVV-USDT-SWAP"},
{Name: "W", BN: "WUSDT", BG: "WUSDT", HL: "W", OK: "W-USDT-SWAP"},
{Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", HL: "WCT", OK: "WCT-USDT-SWAP"},
{Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", HL: "WLD", OK: "WLD-USDT-SWAP"},
{Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", HL: "WLFI", OK: "WLFI-USDT-SWAP"},
{Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", HL: "XAI", OK: "XAI-USDT-SWAP"},
{Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", HL: "XLM", OK: "XLM-USDT-SWAP"},
{Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", HL: "XMR", OK: "XMR-USDT-SWAP"},
{Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", HL: "XPL", OK: "XPL-USDT-SWAP"},
{Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", HL: "XRP", OK: "XRP-USDT-SWAP"},
{Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", HL: "YGG", OK: "YGG-USDT-SWAP"},
{Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", HL: "ZEC", OK: "ZEC-USDT-SWAP"},
{Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", HL: "ZEN", OK: "ZEN-USDT-SWAP"},
{Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", HL: "ZETA", OK: "ZETA-USDT-SWAP"},
{Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", HL: "ZK", OK: "ZK-USDT-SWAP"},
{Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", HL: "ZORA", OK: "ZORA-USDT-SWAP"},
{Name: "ZRO", BN: "ZROUSDT", BG: "ZROUSDT", HL: "ZRO", OK: "ZRO-USDT-SWAP"},
}
// netProfit calculates net profit % after fees for a complete round trip (entry + exit).
+6
View File
@@ -49,6 +49,12 @@ if [ "$CLEAN_DB" = true ]; then
fi
fi
# 设置代理(Clash Verge 本地代理,用于 Binance/OKX/Bitget WS 连接)
# HyperLiquid 直连,无需代理
export HTTPS_PROXY=http://127.0.0.1:7897
export HTTP_PROXY=http://127.0.0.1:7897
export NO_PROXY="api.hyperliquid.xyz,hyperliquid.xyz,localhost,127.0.0.1"
# 编译
NEED_BUILD=false
if [ ! -x "$BIN" ]; then
+9 -13
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
@@ -1277,12 +1276,11 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s
leg.OrderID = resp
log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr)
// Parse actual fill price, OID, and estimate fee from HL response
fillPrice, _, oid, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
// Parse actual fill price from HL response
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
if parseErr == nil && fillPrice > 0 {
leg.EntryPrice = fillPrice
leg.OrderID = strconv.FormatInt(oid, 10)
log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f oid=%d", side, leg.Coin, fillPrice, oid)
log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
}
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid])
if fetchErr != nil {
@@ -1535,12 +1533,11 @@ func (t *Trader) closeLeg(leg *PositionLeg) string {
log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp)
leg.OrderID = resp
// Parse actual fill price and OID from HL close response
fillPrice, _, oid, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
// Parse actual fill price from HL close response
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
if parseErr == nil && fillPrice > 0 {
leg.ExitPrice = fillPrice
leg.OrderID = strconv.FormatInt(oid, 10)
log.Printf("[Fill] HL close %s: actual exitPrice=%.6f oid=%d", leg.Coin, fillPrice, oid)
log.Printf("[Fill] HL close %s: actual exitPrice=%.6f", leg.Coin, fillPrice)
}
}
leg.Closed = true
@@ -1759,12 +1756,11 @@ func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore,
}
leg.OrderID = oid
// Parse actual fill price and OID from HL response
fillPrice, _, oidNum, parseErr := t.hyperliquid.ParseFillFromResponse(oid)
// Parse actual fill price from HL response
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(oid)
if parseErr == nil && fillPrice > 0 {
leg.EntryPrice = fillPrice
leg.OrderID = strconv.FormatInt(oidNum, 10)
log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f oid=%d", side, leg.Coin, fillPrice, oidNum)
log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
}
// Estimate fee from HL response
+478
View File
@@ -0,0 +1,478 @@
package main
import (
"math"
"sort"
"sync"
"time"
)
// TrendState represents the state of a coin's trend detection lifecycle.
type TrendState string
const (
TrendIdle TrendState = "idle"
TrendAlert TrendState = "alert" // anomaly detected, awaiting confirmation
TrendConfirmed TrendState = "confirmed" // trend confirmed by 3+ exchanges
TrendExhausting TrendState = "exhausting" // momentum fading
)
// TrendDirection indicates the direction of a detected trend.
type TrendDirection string
const (
TrendUp TrendDirection = "up"
TrendDown TrendDirection = "down"
)
// TrendEvent records a state transition for one coin, persisted in a ring buffer for UI display.
type TrendEvent struct {
Coin string `json:"coin"`
PrevState string `json:"prev_state"`
NewState string `json:"new_state"`
Direction string `json:"direction"`
ZScore float64 `json:"z_score"`
Volatility float64 `json:"volatility"`
BGChange float64 `json:"bg_change"`
HLChange float64 `json:"hl_change"`
BNChange float64 `json:"bn_change"`
OKXChange float64 `json:"okx_change"`
ExAgree int `json:"ex_agree"`
ExTotal int `json:"ex_total"`
Timestamp int64 `json:"timestamp"`
}
const maxTrendEvents = 500
// TrendEntry is one coin's trend data sent via SSE.
type TrendEntry struct {
Coin string `json:"coin"`
State TrendState `json:"state"`
Direction TrendDirection `json:"direction"`
AnomalyScore float64 `json:"anomaly_score"` // max z-score across all exchanges
Volatility float64 `json:"volatility"` // current EMA volatility baseline
BGChange float64 `json:"bg_change"` // 15s change %
HLChange float64 `json:"hl_change"`
BNChange float64 `json:"bn_change"`
OKXChange float64 `json:"okx_change"`
AlertedAt int64 `json:"alerted_at,omitempty"` // unix millis
ConfirmedAt int64 `json:"confirmed_at,omitempty"` // unix millis
Duration string `json:"duration,omitempty"` // how long in current state
ExChanges int `json:"ex_changes"` // how many exchanges agree on direction
}
// exchangeChange holds the 15s change % for one exchange.
type exchangeChange struct {
name string
change float64
}
// trendCoinState tracks the state machine for one coin.
type trendCoinState struct {
state TrendState
direction TrendDirection
anomalyScore float64
volatility float64
alertedAt time.Time
confirmedAt time.Time
stateSince time.Time
// For confirmation: track how many consecutive ticks agree
confirmCount int
misalignCount int
}
// TrendDetector detects price anomalies and confirms trends across exchanges.
type TrendDetector struct {
mu sync.RWMutex
coins map[string]*trendCoinState
momentum *MomentumTracker
// Configuration
baselineWindow int // ticks for EMA baseline (default: 600 = 30s at 50ms)
anomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
confirmTicks int // ticks needed for confirmation (default: 3)
alertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
// Event history ring buffer (for UI display)
events [maxTrendEvents]TrendEvent
eventsHead int
eventsLen int
// OnEvent is called whenever a state transition is recorded.
// Set this to persist events to database.
OnEvent func(TrendEvent)
}
// NewTrendDetector creates a trend detector that reads from MomentumTracker.
func NewTrendDetector(mt *MomentumTracker) *TrendDetector {
return &TrendDetector{
coins: make(map[string]*trendCoinState),
momentum: mt,
baselineWindow: 600, // ~30s at 50ms tick
anomalyMul: 3.0, // 3 sigma
confirmTicks: 3, // 3 consecutive ticks
alertCooldown: 60000, // 1 min
}
}
// Configure sets trend detection parameters.
func (td *TrendDetector) Configure(baselineWindow int, anomalyMul float64, confirmTicks int, alertCooldownMs int64) {
td.mu.Lock()
defer td.mu.Unlock()
if baselineWindow > 0 {
td.baselineWindow = baselineWindow
}
if anomalyMul > 0 {
td.anomalyMul = anomalyMul
}
if confirmTicks > 0 {
td.confirmTicks = confirmTicks
}
if alertCooldownMs > 0 {
td.alertCooldown = alertCooldownMs
}
}
// recordEvent stores a state transition in the ring buffer.
func (td *TrendDetector) recordEvent(coin, prevState, newState, direction string, zScore, vola float64, bgC, hlC, bnC, okxC float64, exAgree, exTotal int) {
ev := TrendEvent{
Coin: coin,
PrevState: prevState,
NewState: newState,
Direction: direction,
ZScore: math.Round(zScore*100) / 100,
Volatility: math.Round(vola*10000) / 10000,
BGChange: bgC,
HLChange: hlC,
BNChange: bnC,
OKXChange: okxC,
ExAgree: exAgree,
ExTotal: exTotal,
Timestamp: time.Now().UnixMilli(),
}
td.events[td.eventsHead] = ev
td.eventsHead = (td.eventsHead + 1) % maxTrendEvents
if td.eventsLen < maxTrendEvents {
td.eventsLen++
}
// Fire callback for DB persistence
if td.OnEvent != nil {
td.OnEvent(ev)
}
}
// GetEvents returns trend event history, newest first.
func (td *TrendDetector) GetEvents(limit int) []TrendEvent {
td.mu.RLock()
defer td.mu.RUnlock()
n := td.eventsLen
if limit > 0 && limit < n {
n = limit
}
result := make([]TrendEvent, 0, n)
for i := 0; i < n; i++ {
idx := (td.eventsHead - 1 - i + maxTrendEvents) % maxTrendEvents
if td.events[idx].Timestamp == 0 {
continue
}
result = append(result, td.events[idx])
}
return result
}
// Tick runs one iteration of trend detection.
// Reads exchange changes from MomentumTracker buffers, computes volatility baselines,
// and advances the state machine for each coin.
func (td *TrendDetector) Tick() {
// Get all momentum entries to access exchange changes
entries := td.momentum.Snapshot(0)
if len(entries) == 0 {
return
}
td.mu.Lock()
defer td.mu.Unlock()
for _, entry := range entries {
// Collect 15s changes from all 4 exchanges
var changes []exchangeChange
if entry.BG15s != 0 {
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG15s})
}
if entry.HL15s != 0 {
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL15s})
}
if entry.BN15s != 0 {
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN15s})
}
if entry.OKX15s != 0 {
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX15s})
}
if len(changes) < 3 {
continue // need at least 3 exchanges for reliable detection
}
// Compute aggregate stats
_, std := meanStdDev(changes)
maxAbs := 0.0
agreeUp := 0
agreeDown := 0
for _, c := range changes {
abs := math.Abs(c.change)
if abs > maxAbs {
maxAbs = abs
}
if c.change > 0.001 {
agreeUp++
} else if c.change < -0.001 {
agreeDown++
}
}
// Z-score: how anomalous is the max movement?
var zScore float64
if std > 0.0001 {
zScore = maxAbs / std
}
// Update or create coin state
cs, exists := td.coins[entry.Coin]
if !exists {
cs = &trendCoinState{
state: TrendIdle,
stateSince: time.Now(),
}
td.coins[entry.Coin] = cs
}
// Update volatility baseline (EMA of maxAbs)
if cs.volatility == 0 {
cs.volatility = maxAbs
} else {
alpha := 2.0 / float64(td.baselineWindow+1)
cs.volatility = cs.volatility*(1-alpha) + maxAbs*alpha
}
// Update anomaly score
cs.anomalyScore = zScore
// Determine majority direction
majorityDir := TrendUp
majorityCount := agreeUp
if agreeDown > agreeUp {
majorityDir = TrendDown
majorityCount = agreeDown
}
// State machine transitions
now := time.Now()
switch cs.state {
case TrendIdle:
// Alert if z-score exceeds threshold AND majority exchanges agree
if zScore >= td.anomalyMul && majorityCount >= 3 {
cs.state = TrendAlert
cs.direction = majorityDir
cs.alertedAt = now
cs.stateSince = now
cs.confirmCount = 1
cs.misalignCount = 0
td.recordEvent(entry.Coin, "idle", "alert", string(majorityDir),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
case TrendAlert:
// Check if majority still agrees
if majorityCount >= 3 && majorityDir == cs.direction {
cs.confirmCount++
cs.misalignCount = 0
if cs.confirmCount >= td.confirmTicks {
cs.state = TrendConfirmed
cs.confirmedAt = now
cs.stateSince = now
td.recordEvent(entry.Coin, "alert", "confirmed", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
} else {
cs.misalignCount++
if cs.misalignCount >= td.confirmTicks {
// Failed to confirm — back to idle
cs.state = TrendIdle
cs.stateSince = now
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "alert", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
}
case TrendConfirmed:
// Check if momentum is exhausting (fewer than 3 exchanges agree)
// Also track if z-score drops below threshold
if majorityCount < 2 || zScore < td.anomalyMul*0.5 {
cs.state = TrendExhausting
cs.stateSince = now
td.recordEvent(entry.Coin, "confirmed", "exhausting", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
case TrendExhausting:
// After exhausting, go back to idle
if time.Since(cs.stateSince) > 5*time.Second {
cs.state = TrendIdle
cs.stateSince = now
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
// Also immediately go to idle if below threshold
if zScore < td.anomalyMul*0.3 || majorityCount < 1 {
cs.state = TrendIdle
cs.stateSince = now
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
majorityCount, len(changes))
}
}
}
// Cleanup stale entries (no update for > 60s)
cutoff := time.Now().Add(-60 * time.Second)
for coin, cs := range td.coins {
if cs.state == TrendIdle && cs.stateSince.Before(cutoff) {
delete(td.coins, coin)
}
}
}
// Snapshot returns current trend state for all coins.
func (td *TrendDetector) Snapshot() []TrendEntry {
td.mu.RLock()
defer td.mu.RUnlock()
entries := td.momentum.Snapshot(0)
entryMap := make(map[string]MomentumEntry, len(entries))
for _, e := range entries {
entryMap[e.Coin] = e
}
var result []TrendEntry
for coin, cs := range td.coins {
if cs.state == TrendIdle {
continue // skip idle coins
}
entry := TrendEntry{
Coin: coin,
State: cs.state,
Direction: cs.direction,
AnomalyScore: math.Round(cs.anomalyScore*100) / 100,
Volatility: math.Round(cs.volatility*10000) / 10000,
ExChanges: 0,
}
if me, ok := entryMap[coin]; ok {
entry.BGChange = me.BG15s
entry.HLChange = me.HL15s
entry.BNChange = me.BN15s
entry.OKXChange = me.OKX15s
// Count how many exchanges agree with the trend direction
agree := 0
changes := []float64{entry.BGChange, entry.HLChange, entry.BNChange, entry.OKXChange}
for _, c := range changes {
if cs.direction == TrendUp && c > 0.001 {
agree++
} else if cs.direction == TrendDown && c < -0.001 {
agree++
}
}
entry.ExChanges = agree
}
if !cs.alertedAt.IsZero() {
entry.AlertedAt = cs.alertedAt.UnixMilli()
}
if !cs.confirmedAt.IsZero() {
entry.ConfirmedAt = cs.confirmedAt.UnixMilli()
}
// Duration in current state
dur := time.Since(cs.stateSince).Round(time.Second)
entry.Duration = dur.String()
result = append(result, entry)
}
// Sort: confirmed first, then alert, then exhausting
sort.Slice(result, func(i, j int) bool {
order := map[TrendState]int{
TrendConfirmed: 0,
TrendAlert: 1,
TrendExhausting: 2,
}
oi := order[result[i].State]
oj := order[result[j].State]
if oi != oj {
return oi < oj
}
return result[i].AnomalyScore > result[j].AnomalyScore
})
return result
}
// meanStdDev computes mean and standard deviation of exchange change values.
func meanStdDev(changes []exchangeChange) (mean, stdDev float64) {
if len(changes) == 0 {
return 0, 0
}
var sum float64
for _, c := range changes {
sum += c.change
}
mean = sum / float64(len(changes))
var varianceSum float64
for _, c := range changes {
diff := c.change - mean
varianceSum += diff * diff
}
variance := varianceSum / float64(len(changes))
stdDev = math.Sqrt(variance)
return mean, stdDev
}
// IsTrending returns true if the given coin is in confirmed trend state.
func (td *TrendDetector) IsTrending(coin string) bool {
td.mu.RLock()
defer td.mu.RUnlock()
cs, ok := td.coins[coin]
return ok && cs.state == TrendConfirmed
}
// GetTrendingCoins returns all coins currently in confirmed trend.
func (td *TrendDetector) GetTrendingCoins() map[string]TrendDirection {
td.mu.RLock()
defer td.mu.RUnlock()
result := make(map[string]TrendDirection)
for coin, cs := range td.coins {
if cs.state == TrendConfirmed {
result[coin] = cs.direction
}
}
return result
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"math"
"testing"
)
func TestMeanStdDev(t *testing.T) {
changes := []exchangeChange{
{name: "A", change: 0.1},
{name: "B", change: 0.2},
{name: "C", change: 0.3},
{name: "D", change: 0.4},
}
mean, std := meanStdDev(changes)
if math.Abs(mean-0.25) > 0.001 {
t.Errorf("mean = %.4f, want 0.2500", mean)
}
if math.Abs(std-0.1118) > 0.01 {
t.Errorf("std = %.4f, want ~0.1118", std)
}
}
func TestMeanStdDevSingleValue(t *testing.T) {
changes := []exchangeChange{
{name: "A", change: 0.1},
}
mean, std := meanStdDev(changes)
if mean != 0.1 {
t.Errorf("mean = %.4f, want 0.1000", mean)
}
if std != 0 {
t.Errorf("std = %.4f, want 0.0000", std)
}
}
func TestMeanStdDevZeroValues(t *testing.T) {
changes := []exchangeChange{
{name: "A", change: 0},
{name: "B", change: 0},
}
mean, std := meanStdDev(changes)
if mean != 0 {
t.Errorf("mean = %.4f, want 0.0000", mean)
}
if std != 0 {
t.Errorf("std = %.4f, want 0.0000", std)
}
}
func TestMeanStdDevEmpty(t *testing.T) {
mean, std := meanStdDev(nil)
if mean != 0 || std != 0 {
t.Errorf("expected 0,0 for empty input, got %.4f, %.4f", mean, std)
}
}
func TestZScoreCalculation(t *testing.T) {
// One exchange strongly diverging from the others
// Three exchanges nearly flat, one moves 1.5%
changes := []exchangeChange{
{name: "BG", change: 0.01},
{name: "HL", change: 0.01},
{name: "BN", change: 0.02},
{name: "OK", change: 1.50}, // anomalous!
}
_, std := meanStdDev(changes)
maxAbs := 1.50
zScore := maxAbs / std
if zScore < 2.0 {
t.Errorf("z-score = %.2f, expected > 2.0 for divergent exchange", zScore)
}
t.Logf("Divergent exchange (1 of 4): z-score = %.2f (std = %.4f)", zScore, std)
}
func TestCoordinatedMovement(t *testing.T) {
// All exchanges moving together = also a trend (consensus, not anomaly)
changes := []exchangeChange{
{name: "BG", change: 0.05},
{name: "HL", change: 0.06},
{name: "BN", change: 0.04},
{name: "OK", change: 0.07},
}
_, std := meanStdDev(changes)
maxAbs := 0.07
zScore := maxAbs / std
// Tight cluster → std is small, z-score is high → valid trend signal
t.Logf("Co-movement: z-score = %.2f (std = %.4f) — high z-score + 4/4 agreement = trend", zScore, std)
}
func TestTrendDetectorConfigure(t *testing.T) {
td := NewTrendDetector(nil)
if td.anomalyMul != 3.0 {
t.Errorf("default anomalyMul = %.1f, want 3.0", td.anomalyMul)
}
if td.confirmTicks != 3 {
t.Errorf("default confirmTicks = %d, want 3", td.confirmTicks)
}
td.Configure(300, 2.5, 2, 30000)
if td.anomalyMul != 2.5 {
t.Errorf("anomalyMul after configure = %.1f, want 2.5", td.anomalyMul)
}
if td.confirmTicks != 2 {
t.Errorf("confirmTicks after configure = %d, want 2", td.confirmTicks)
}
}
func TestTrendDetectorEmptyConfigure(t *testing.T) {
td := NewTrendDetector(nil)
// Passing zeros should keep defaults
td.Configure(0, 0, 0, 0)
if td.anomalyMul != 3.0 {
t.Errorf("anomalyMul = %.1f, expected default 3.0", td.anomalyMul)
}
}
+1
View File
@@ -12,6 +12,7 @@ type TrackedCoin struct {
BN string // Binance symbol (BTCUSDT)
BG string // Bitget symbol (BTCUSDT)
HL string // HyperLiquid symbol (BTC)
OK string // OKX symbol (BTC-USDT-SWAP)
}
// PriceTick holds a price update with optional bid/ask.