feat: 添加OKX行情接入+趋势检测+累积变动系统+界面重构
- 新增OKX WebSocket行情连接器,扩展4交易所价格监控 - 新增z-score趋势检测引擎(TrendDetector),识别价格异动/趋势启动 - 新增累积变动跟踪(CumulativeTracker),基于1min/5min多交易所共识 - 趋势事件和累积变动事件持久化到SQLite - 新增Binance/OKX动量检测字段,扩展前端动量卡片至15列 - 迁移至macOS(darwin-arm64),更新前端依赖 - Dashboard网格重构:非交易卡片置顶,交易卡片置底 - TrackedCoin添加OK字段,添加ExBinance/ExOKX常量 - 前端新增趋势检测卡片、趋势历史卡片、累积变动卡片 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
047571921e
commit
b7767c95ae
@@ -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.
|
||||||
@@ -52,6 +52,17 @@ type Config struct {
|
|||||||
// Entry sanity check: reject if price moved beyond this % in the wrong direction
|
// Entry sanity check: reject if price moved beyond this % in the wrong direction
|
||||||
ReversalTolerancePct float64
|
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
|
// Bitget API
|
||||||
BitgetAPIKey string
|
BitgetAPIKey string
|
||||||
BitgetAPISecret string
|
BitgetAPISecret string
|
||||||
@@ -79,6 +90,17 @@ type jsonConfig struct {
|
|||||||
InitialCapital float64 `json:"initial_capital"`
|
InitialCapital float64 `json:"initial_capital"`
|
||||||
ExcludedCoins []string `json:"excluded_coins"`
|
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
|
// New: exchange fees
|
||||||
TakerFeeBitget float64 `json:"taker_fee_bitget"`
|
TakerFeeBitget float64 `json:"taker_fee_bitget"`
|
||||||
TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"`
|
TakerFeeHyperLiquid float64 `json:"taker_fee_hyperliquid"`
|
||||||
@@ -158,6 +180,17 @@ func LoadConfig() *Config {
|
|||||||
|
|
||||||
ExcludedCoins: jsonCfg.ExcludedCoins,
|
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", ""),
|
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
||||||
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
||||||
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
|
BitgetPassphrase: getEnv("BITGET_PASSPHRASE", ""),
|
||||||
@@ -194,6 +227,15 @@ func loadJSONConfig() jsonConfig {
|
|||||||
// Scale-in parameters
|
// Scale-in parameters
|
||||||
ScaleStepPct: 0.10, // 0.10% spread widening per scale level
|
ScaleStepPct: 0.10, // 0.10% spread widening per scale level
|
||||||
ScaleCooldownSec: 5, // 5 seconds between scales
|
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")
|
data, err := os.ReadFile("config.json")
|
||||||
@@ -267,11 +309,31 @@ func loadJSONConfig() jsonConfig {
|
|||||||
def.ExcludedCoins = cfg.ExcludedCoins
|
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
|
// Boolean fields: zero default is false, so use OR logic
|
||||||
// When JSON has true → true || false = true (override)
|
// When JSON has true → true || false = true (override)
|
||||||
// When JSON has false → false || false = false (keep default)
|
// When JSON has false → false || false = false (keep default)
|
||||||
def.TestMode = cfg.TestMode || def.TestMode
|
def.TestMode = cfg.TestMode || def.TestMode
|
||||||
def.TradeEnabled = cfg.TradeEnabled || def.TradeEnabled
|
def.TradeEnabled = cfg.TradeEnabled || def.TradeEnabled
|
||||||
|
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
|
||||||
|
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
|
||||||
|
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -19,5 +19,8 @@
|
|||||||
"leg_delay_ms": 300,
|
"leg_delay_ms": 300,
|
||||||
"reversal_tolerance_pct": 0.1,
|
"reversal_tolerance_pct": 0.1,
|
||||||
"scale_step_pct": 0.3,
|
"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
@@ -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
@@ -189,6 +189,7 @@ type Dashboard struct {
|
|||||||
trader *Trader
|
trader *Trader
|
||||||
db *db.DB
|
db *db.DB
|
||||||
addr string
|
addr string
|
||||||
|
cfg *Config
|
||||||
|
|
||||||
// cached arb scan results
|
// cached arb scan results
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -198,10 +199,19 @@ type Dashboard struct {
|
|||||||
// P3-5: connection status — exchange -> last update time
|
// P3-5: connection status — exchange -> last update time
|
||||||
connMu sync.RWMutex
|
connMu sync.RWMutex
|
||||||
connMap map[string]time.Time // exchange name -> last price timestamp
|
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 {
|
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker) *Dashboard {
|
||||||
return &Dashboard{
|
d := &Dashboard{
|
||||||
hub: NewSSEHub(),
|
hub: NewSSEHub(),
|
||||||
history: newPriceHistory(),
|
history: newPriceHistory(),
|
||||||
spreads: newSpreadHistory(),
|
spreads: newSpreadHistory(),
|
||||||
@@ -209,8 +219,33 @@ func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr strin
|
|||||||
trader: trader,
|
trader: trader,
|
||||||
db: database,
|
db: database,
|
||||||
addr: addr,
|
addr: addr,
|
||||||
|
cfg: cfg,
|
||||||
connMap: make(map[string]time.Time),
|
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() {
|
func (d *Dashboard) Run() {
|
||||||
@@ -239,6 +274,8 @@ func (d *Dashboard) Run() {
|
|||||||
mux.HandleFunc("GET /api/trades", d.handleTrades)
|
mux.HandleFunc("GET /api/trades", d.handleTrades)
|
||||||
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
|
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
|
||||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
|
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("GET /events", d.handleSSE)
|
||||||
mux.HandleFunc("POST /api/stop", d.handleStop)
|
mux.HandleFunc("POST /api/stop", d.handleStop)
|
||||||
mux.HandleFunc("POST /api/start", d.handleStart)
|
mux.HandleFunc("POST /api/start", d.handleStart)
|
||||||
@@ -534,6 +571,32 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
stats["blacklist"] = blList
|
stats["blacklist"] = blList
|
||||||
|
|
||||||
d.hub.Broadcast("stats", stats)
|
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)
|
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) {
|
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
|
||||||
if d.db == nil {
|
if d.db == nil {
|
||||||
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -108,6 +108,48 @@ func (d *DB) migrate() error {
|
|||||||
created_at DATETIME NOT NULL
|
created_at DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_system_orders_trade ON system_orders(trade_id);
|
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)
|
_, err := d.Exec(schema)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
@@ -4,39 +4,50 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BinanceWS connects to Binance WS for ticker data.
|
||||||
|
// Splits symbols across multiple combined-stream connections.
|
||||||
type BinanceWS struct {
|
type BinanceWS struct {
|
||||||
Tracked []string
|
Tracked []string
|
||||||
|
connections int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBinanceWS(tracked []string) *BinanceWS {
|
func NewBinanceWS(tracked []string) *BinanceWS {
|
||||||
return &BinanceWS{Tracked: tracked}
|
conns := int(math.Ceil(float64(len(tracked)) / 60))
|
||||||
|
if conns < 1 {
|
||||||
|
conns = 1
|
||||||
}
|
}
|
||||||
// Run connects to Binance WS and streams bookTicker data.
|
if conns > 10 {
|
||||||
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
conns = 10
|
||||||
|
}
|
||||||
|
return &BinanceWS{Tracked: tracked, connections: conns}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BinanceWS) runSingle(symbols []string, connIdx int, updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
streams := ""
|
streams := ""
|
||||||
for i, sym := range b.Tracked {
|
for i, sym := range symbols {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
streams += "/"
|
streams += "/"
|
||||||
}
|
}
|
||||||
streams += fmt.Sprintf("%s@bookTicker", strings.ToLower(sym))
|
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)
|
url := fmt.Sprintf("wss://fstream.binance.com/stream?streams=%s", streams)
|
||||||
conn.PingInterval = 45 * time.Second
|
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() {
|
conn.OnConnect = func() {
|
||||||
log.Printf("[Binance WS] Connected")
|
log.Printf("[%s] Connected (%d symbols)", name, len(symbols))
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.OnMessage = func(msg []byte) {
|
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
|
var raw map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(msg, &raw); err != nil {
|
if err := json.Unmarshal(msg, &raw); err != nil {
|
||||||
return
|
return
|
||||||
@@ -46,12 +57,12 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
|||||||
return
|
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{}
|
var dataMap map[string]interface{}
|
||||||
if err := json.Unmarshal(dataRaw, &dataMap); err != nil {
|
if err := json.Unmarshal(dataRaw, &dataMap); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
symbol, _ := dataMap["s"].(string)
|
symbol, _ := dataMap["s"].(string)
|
||||||
bidStr, _ := dataMap["b"].(string)
|
bidStr, _ := dataMap["b"].(string)
|
||||||
askStr, _ := dataMap["a"].(string)
|
askStr, _ := dataMap["a"].(string)
|
||||||
@@ -59,13 +70,12 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bid, err1 := strconv.ParseFloat(bidStr, 64)
|
bid := parseFloat(bidStr)
|
||||||
ask, err2 := strconv.ParseFloat(askStr, 64)
|
ask := parseFloat(askStr)
|
||||||
if err1 != nil || err2 != nil || bid <= 0 || ask <= 0 {
|
if bid <= 0 || ask <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract coin name (e.g., "BTCUSDT" -> "BTC")
|
|
||||||
coin := symbolToCoin(symbol, "USDT")
|
coin := symbolToCoin(symbol, "USDT")
|
||||||
if coin == "" {
|
if coin == "" {
|
||||||
return
|
return
|
||||||
@@ -77,3 +87,31 @@ func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) err
|
|||||||
|
|
||||||
return conn.Run()
|
return conn.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BinanceWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
if len(b.Tracked) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n := b.connections
|
||||||
|
perConn := (len(b.Tracked) + n - 1) / n
|
||||||
|
|
||||||
|
errCh := make(chan error, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
start := i * perConn
|
||||||
|
end := start + perConn
|
||||||
|
if end > len(b.Tracked) {
|
||||||
|
end = len(b.Tracked)
|
||||||
|
}
|
||||||
|
if start >= end {
|
||||||
|
errCh <- nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch := b.Tracked[start:end]
|
||||||
|
go func(idx int, syms []string) {
|
||||||
|
errCh <- b.runSingle(syms, idx, updateFn)
|
||||||
|
}(i+1, batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <-errCh
|
||||||
|
}
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OKXWS connects to OKX WebSocket for tickers channel (perpetual swaps).
|
||||||
|
type OKXWS struct {
|
||||||
|
Tracked []string // OKX symbols like BTC-USDT-SWAP
|
||||||
|
}
|
||||||
|
|
||||||
|
type okxSubscribeMsg struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Args []okxChannel `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okxChannel struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
InstID string `json:"instId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okxTickerMsg struct {
|
||||||
|
Arg okxChannel `json:"arg"`
|
||||||
|
Data []okxTickerData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okxTickerData struct {
|
||||||
|
Last string `json:"last"`
|
||||||
|
BidPx string `json:"bidPx"`
|
||||||
|
AskPx string `json:"askPx"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOKXWS(tracked []string) *OKXWS {
|
||||||
|
return &OKXWS{
|
||||||
|
Tracked: tracked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects to OKX WS and streams ticker data.
|
||||||
|
func (o *OKXWS) Run(updateFn func(coin string, price, bid, ask float64)) error {
|
||||||
|
url := "wss://ws.okx.com:8443/ws/v5/public"
|
||||||
|
|
||||||
|
conn := NewPriceConnector(url, "OKX", 120*time.Second, 30*time.Second)
|
||||||
|
conn.PingInterval = 20 * time.Second // OKX requires ping within 30s
|
||||||
|
conn.TextPing = true // OKX expects text "ping" message
|
||||||
|
|
||||||
|
conn.OnConnect = func() {
|
||||||
|
log.Printf("[OKX WS] Connected, subscribing (%d symbols)", len(o.Tracked))
|
||||||
|
|
||||||
|
// Batch subscriptions — OKX has rate limits (3 req/s, 480/hr)
|
||||||
|
batchSize := 20
|
||||||
|
for i := 0; i < len(o.Tracked); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(o.Tracked) {
|
||||||
|
end = len(o.Tracked)
|
||||||
|
}
|
||||||
|
batch := o.Tracked[i:end]
|
||||||
|
args := make([]okxChannel, len(batch))
|
||||||
|
for j, sym := range batch {
|
||||||
|
args[j] = okxChannel{
|
||||||
|
Channel: "tickers",
|
||||||
|
InstID: sym,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub := okxSubscribeMsg{
|
||||||
|
Op: "subscribe",
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
if err := conn.SendJSON(sub); err != nil {
|
||||||
|
log.Printf("[OKX WS] Subscribe error (batch %d): %v", i/batchSize, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.OnMessage = func(msg []byte) {
|
||||||
|
// Handle OKX text "pong" response
|
||||||
|
if string(msg) == "pong" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for subscription confirmation or error response
|
||||||
|
var generic map[string]interface{}
|
||||||
|
if err := json.Unmarshal(msg, &generic); err == nil {
|
||||||
|
if evt, _ := generic["event"].(string); evt == "error" || evt == "subscribe" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticker okxTickerMsg
|
||||||
|
if err := json.Unmarshal(msg, &ticker); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ticker.Data) == 0 || ticker.Data[0].Last == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert BTC-USDT-SWAP -> BTC
|
||||||
|
coin := okxSymbolToCoin(ticker.Arg.InstID)
|
||||||
|
if coin == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
price := parseFloat(ticker.Data[0].Last)
|
||||||
|
if price > 0 {
|
||||||
|
bid := parseFloat(ticker.Data[0].BidPx)
|
||||||
|
ask := parseFloat(ticker.Data[0].AskPx)
|
||||||
|
updateFn(coin, price, bid, ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// okxSymbolToCoin converts "BTC-USDT-SWAP" to "BTC".
|
||||||
|
func okxSymbolToCoin(symbol string) string {
|
||||||
|
// Strip "-USDT-SWAP" suffix
|
||||||
|
const suffix = "-USDT-SWAP"
|
||||||
|
if !strings.HasSuffix(symbol, suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return symbol[:len(symbol)-len(suffix)]
|
||||||
|
}
|
||||||
-40
File diff suppressed because one or more lines are too long
+40
File diff suppressed because one or more lines are too long
+1
-1
@@ -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}
|
||||||
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Exchange Monitor Dashboard</title>
|
<title>Exchange Monitor Dashboard</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-D_JzXaOQ.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-Dr4jUtK1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-Bh5bnFYE.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-DsdSIpuQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -224,3 +224,21 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
|||||||
.detail-orders { width: 100%; font-size: 12px; }
|
.detail-orders { width: 100%; font-size: 12px; }
|
||||||
.detail-orders th { background: var(--bg); font-size: 10px; }
|
.detail-orders th { background: var(--bg); font-size: 10px; }
|
||||||
.detail-orders td { padding: 4px 6px; }
|
.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
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
const EXCHANGES = ['HyperLiquid', 'Bitget']
|
const EXCHANGES = ['HyperLiquid', 'Bitget', 'Binance', 'OKX']
|
||||||
|
|
||||||
function formatPrice(p) {
|
function formatPrice(p) {
|
||||||
if (p == null || p <= 0) return '-'
|
if (p == null || p <= 0) return '-'
|
||||||
@@ -26,6 +26,11 @@ export default function App() {
|
|||||||
const [blacklist, setBlacklist] = useState([])
|
const [blacklist, setBlacklist] = useState([])
|
||||||
const [stats, setStats] = useState({})
|
const [stats, setStats] = useState({})
|
||||||
const [trades, setTrades] = 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({})
|
const priceCacheRef = useRef({})
|
||||||
|
|
||||||
// Clock
|
// Clock
|
||||||
@@ -69,6 +74,15 @@ export default function App() {
|
|||||||
case 'blacklist':
|
case 'blacklist':
|
||||||
setBlacklist(msg.data || [])
|
setBlacklist(msg.data || [])
|
||||||
break
|
break
|
||||||
|
case 'momentum':
|
||||||
|
setMomentum(msg.data || [])
|
||||||
|
break
|
||||||
|
case 'trend':
|
||||||
|
setTrendData(msg.data || [])
|
||||||
|
break
|
||||||
|
case 'cumulative':
|
||||||
|
setCmData(msg.data || [])
|
||||||
|
break
|
||||||
case 'stats':
|
case 'stats':
|
||||||
setStats(msg.data || {})
|
setStats(msg.data || {})
|
||||||
if (msg.data && msg.data.blacklist) {
|
if (msg.data && msg.data.blacklist) {
|
||||||
@@ -104,6 +118,40 @@ export default function App() {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [loadTrades])
|
}, [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
|
// Handle prices
|
||||||
function handlePrices(data) {
|
function handlePrices(data) {
|
||||||
if (!data || data.length === 0) return
|
if (!data || data.length === 0) return
|
||||||
@@ -162,6 +210,29 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid">
|
<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 */}
|
{/* Stats Summary */}
|
||||||
<StatsCard stats={stats} />
|
<StatsCard stats={stats} />
|
||||||
|
|
||||||
@@ -171,12 +242,6 @@ export default function App() {
|
|||||||
{/* PnL Growth Chart */}
|
{/* PnL Growth Chart */}
|
||||||
<PnlChart />
|
<PnlChart />
|
||||||
|
|
||||||
{/* Price Table */}
|
|
||||||
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
|
|
||||||
|
|
||||||
{/* Arbitrage Opportunities */}
|
|
||||||
<ArbTable opps={opps} />
|
|
||||||
|
|
||||||
{/* Recent Trades */}
|
{/* Recent Trades */}
|
||||||
<TradesCard trades={trades} onRefresh={loadTrades} />
|
<TradesCard trades={trades} onRefresh={loadTrades} />
|
||||||
|
|
||||||
@@ -333,11 +398,11 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
|
|||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table id="price-table">
|
<table id="price-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>毛价差</th><th>BG→HL净利</th><th>HL→BG净利</th></tr>
|
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>Binance</th><th>OKX</th><th>毛价差</th><th>BG→HL净利</th><th>HL→BG净利</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="price-body">
|
<tbody id="price-body">
|
||||||
{coins.length === 0 ? (
|
{coins.length === 0 ? (
|
||||||
<tr><td colSpan="6" className="loading">等待数据...</td></tr>
|
<tr><td colSpan="8" className="loading">等待数据...</td></tr>
|
||||||
) : coins.map(coin => {
|
) : coins.map(coin => {
|
||||||
const row = prices.find(p => p.coin === coin)
|
const row = prices.find(p => p.coin === coin)
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -770,3 +835,449 @@ function PnlChart() {
|
|||||||
</section>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ func main() {
|
|||||||
store := NewPriceStore()
|
store := NewPriceStore()
|
||||||
notifier := NewNotifier(cfg.TelegramBotToken, cfg.TelegramChatID)
|
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
|
// Initialize SQLite database
|
||||||
database, err := db.Open("")
|
database, err := db.Open("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +90,7 @@ func main() {
|
|||||||
trader.startIPCServer()
|
trader.startIPCServer()
|
||||||
|
|
||||||
// Initialize dashboard (web server + SSE)
|
// Initialize dashboard (web server + SSE)
|
||||||
dashboard := NewDashboard(store, trader, database, ":8888")
|
dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker)
|
||||||
go dashboard.Run()
|
go dashboard.Run()
|
||||||
|
|
||||||
// Spread window tracker — measures how long spreads stay above threshold
|
// Spread window tracker — measures how long spreads stay above threshold
|
||||||
@@ -83,7 +98,9 @@ func main() {
|
|||||||
|
|
||||||
// P3-4: wire real-time trade event broadcast
|
// P3-4: wire real-time trade event broadcast
|
||||||
trader.OnTradeEvent = dashboard.BroadcastEvent
|
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)",
|
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)
|
trader.ModeLabel(), cfg.TradeThreshold, cfg.TradeAmountUSD, cfg.MaxPositions, cfg.InitialCapital)
|
||||||
if cfg.TestMode {
|
if cfg.TestMode {
|
||||||
@@ -101,8 +118,8 @@ func main() {
|
|||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGUSR1)
|
||||||
|
|
||||||
// Collect symbols — only BG and HL for now (BN, dYdX disabled)
|
// Collect symbols for all exchanges
|
||||||
var bgSymbols, hlSymbols []string
|
var bgSymbols, hlSymbols, bnSymbols, okxSymbols []string
|
||||||
for _, c := range TrackedCoins {
|
for _, c := range TrackedCoins {
|
||||||
if c.BG != "" {
|
if c.BG != "" {
|
||||||
bgSymbols = append(bgSymbols, c.BG)
|
bgSymbols = append(bgSymbols, c.BG)
|
||||||
@@ -110,9 +127,15 @@ func main() {
|
|||||||
if c.HL != "" {
|
if c.HL != "" {
|
||||||
hlSymbols = append(hlSymbols, 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) {
|
startExchange := func(name string, runner func(func(string, float64, float64, float64)) error) {
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
@@ -133,6 +156,8 @@ func main() {
|
|||||||
|
|
||||||
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
|
startExchange("HyperLiquid", exchange.NewHyperLiquidWS(hlSymbols).Run)
|
||||||
startExchange("Bitget", exchange.NewBitgetWS(bgSymbols).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...")
|
log.Println("[Monitor] Waiting for initial data...")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
@@ -206,6 +231,25 @@ func main() {
|
|||||||
|
|
||||||
// Scan for arbitrage entries using maker fees (limit orders)
|
// Scan for arbitrage entries using maker fees (limit orders)
|
||||||
snap := store.GetAll()
|
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)
|
makerOpps := ScanBGHL(snap)
|
||||||
dashboard.UpdateScan(makerOpps)
|
dashboard.UpdateScan(makerOpps)
|
||||||
t2 := time.Now()
|
t2 := time.Now()
|
||||||
@@ -213,6 +257,8 @@ func main() {
|
|||||||
// Track spread window durations (how long each opportunity stays alive)
|
// Track spread window durations (how long each opportunity stays alive)
|
||||||
spreadTracker.Tick(snap, cfg.TradeThreshold)
|
spreadTracker.Tick(snap, cfg.TradeThreshold)
|
||||||
|
|
||||||
|
// In momentum mode, arbitrage trading is disabled
|
||||||
|
if !cfg.MomentumEnabled {
|
||||||
for _, opp := range makerOpps {
|
for _, opp := range makerOpps {
|
||||||
if opp.NetProfit < cfg.ArbThreshold {
|
if opp.NetProfit < cfg.ArbThreshold {
|
||||||
continue
|
continue
|
||||||
@@ -221,6 +267,7 @@ func main() {
|
|||||||
log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit)
|
log.Printf("[Trader] %s: entry initiated for %.4f%%", opp.Coin, opp.NetProfit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
t3 := time.Now()
|
t3 := time.Now()
|
||||||
|
|
||||||
// Profile: warn if any step is slow
|
// Profile: warn if any step is slow
|
||||||
|
|||||||
+199
@@ -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
@@ -4,10 +4,12 @@ import (
|
|||||||
"sort"
|
"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 (
|
const (
|
||||||
ExHyperLiquid = "HyperLiquid"
|
ExHyperLiquid = "HyperLiquid"
|
||||||
ExBitget = "Bitget"
|
ExBitget = "Bitget"
|
||||||
|
ExBinance = "Binance"
|
||||||
|
ExOKX = "OKX"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Taker fee rates (%) — for IOC market orders on trading exchanges
|
// Taker fee rates (%) — for IOC market orders on trading exchanges
|
||||||
@@ -17,181 +19,181 @@ var takerFees = map[string]float64{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var TrackedCoins = []TrackedCoin{
|
var TrackedCoins = []TrackedCoin{
|
||||||
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE"},
|
{Name: "DOGE", BN: "DOGEUSDT", BG: "DOGEUSDT", HL: "DOGE", OK: "DOGE-USDT-SWAP"},
|
||||||
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK"},
|
{Name: "LINK", BN: "LINKUSDT", BG: "LINKUSDT", HL: "LINK", OK: "LINK-USDT-SWAP"},
|
||||||
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO"},
|
{Name: "ONDO", BN: "ONDOUSDT", BG: "ONDOUSDT", HL: "ONDO", OK: "ONDO-USDT-SWAP"},
|
||||||
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP"},
|
{Name: "OP", BN: "OPUSDT", BG: "OPUSDT", HL: "OP", OK: "OP-USDT-SWAP"},
|
||||||
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF"},
|
{Name: "WIF", BN: "WIFUSDT", BG: "WIFUSDT", HL: "WIF", OK: "WIF-USDT-SWAP"},
|
||||||
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB"},
|
{Name: "ARB", BN: "ARBUSDT", BG: "ARBUSDT", HL: "ARB", OK: "ARB-USDT-SWAP"},
|
||||||
{Name: "0G", BN: "", BG: "0GUSDT", HL: "0G"},
|
{Name: "0G", BN: "0GUSDT", BG: "0GUSDT", HL: "0G", OK: "0G-USDT-SWAP"},
|
||||||
{Name: "2Z", BN: "", BG: "2ZUSDT", HL: "2Z"},
|
{Name: "2Z", BN: "2ZUSDT", BG: "2ZUSDT", HL: "2Z", OK: "2Z-USDT-SWAP"},
|
||||||
{Name: "AAVE", BN: "", BG: "AAVEUSDT", HL: "AAVE"},
|
{Name: "AAVE", BN: "AAVEUSDT", BG: "AAVEUSDT", HL: "AAVE", OK: "AAVE-USDT-SWAP"},
|
||||||
{Name: "ACE", BN: "", BG: "ACEUSDT", HL: "ACE"},
|
{Name: "ACE", BN: "ACEUSDT", BG: "ACEUSDT", HL: "ACE", OK: "ACE-USDT-SWAP"},
|
||||||
{Name: "ADA", BN: "", BG: "ADAUSDT", HL: "ADA"},
|
{Name: "ADA", BN: "ADAUSDT", BG: "ADAUSDT", HL: "ADA", OK: "ADA-USDT-SWAP"},
|
||||||
{Name: "AIXBT", BN: "", BG: "AIXBTUSDT", HL: "AIXBT"},
|
{Name: "AIXBT", BN: "AIXBTUSDT", BG: "AIXBTUSDT", HL: "AIXBT", OK: "AIXBT-USDT-SWAP"},
|
||||||
{Name: "ALGO", BN: "", BG: "ALGOUSDT", HL: "ALGO"},
|
{Name: "ALGO", BN: "ALGOUSDT", BG: "ALGOUSDT", HL: "ALGO", OK: "ALGO-USDT-SWAP"},
|
||||||
{Name: "ALT", BN: "", BG: "ALTUSDT", HL: "ALT"},
|
{Name: "ALT", BN: "ALTUSDT", BG: "ALTUSDT", HL: "ALT", OK: "ALT-USDT-SWAP"},
|
||||||
{Name: "ANIME", BN: "", BG: "ANIMEUSDT", HL: "ANIME"},
|
{Name: "ANIME", BN: "ANIMEUSDT", BG: "ANIMEUSDT", HL: "ANIME", OK: "ANIME-USDT-SWAP"},
|
||||||
{Name: "APE", BN: "", BG: "APEUSDT", HL: "APE"},
|
{Name: "APE", BN: "APEUSDT", BG: "APEUSDT", HL: "APE", OK: "APE-USDT-SWAP"},
|
||||||
{Name: "APT", BN: "", BG: "APTUSDT", HL: "APT"},
|
{Name: "APT", BN: "APTUSDT", BG: "APTUSDT", HL: "APT", OK: "APT-USDT-SWAP"},
|
||||||
{Name: "AR", BN: "", BG: "ARUSDT", HL: "AR"},
|
{Name: "AR", BN: "ARUSDT", BG: "ARUSDT", HL: "AR", OK: "AR-USDT-SWAP"},
|
||||||
{Name: "ARK", BN: "", BG: "ARKUSDT", HL: "ARK"},
|
{Name: "ARK", BN: "ARKUSDT", BG: "ARKUSDT", HL: "ARK", OK: "ARK-USDT-SWAP"},
|
||||||
{Name: "ASTER", BN: "", BG: "ASTERUSDT", HL: "ASTER"},
|
{Name: "ASTER", BN: "ASTERUSDT", BG: "ASTERUSDT", HL: "ASTER", OK: "ASTER-USDT-SWAP"},
|
||||||
{Name: "ATOM", BN: "", BG: "ATOMUSDT", HL: "ATOM"},
|
{Name: "ATOM", BN: "ATOMUSDT", BG: "ATOMUSDT", HL: "ATOM", OK: "ATOM-USDT-SWAP"},
|
||||||
{Name: "AVAX", BN: "", BG: "AVAXUSDT", HL: "AVAX"},
|
{Name: "AVAX", BN: "AVAXUSDT", BG: "AVAXUSDT", HL: "AVAX", OK: "AVAX-USDT-SWAP"},
|
||||||
{Name: "AVNT", BN: "", BG: "AVNTUSDT", HL: "AVNT"},
|
{Name: "AVNT", BN: "AVNTUSDT", BG: "AVNTUSDT", HL: "AVNT", OK: "AVNT-USDT-SWAP"},
|
||||||
{Name: "AXS", BN: "", BG: "AXSUSDT", HL: "AXS"},
|
{Name: "AXS", BN: "AXSUSDT", BG: "AXSUSDT", HL: "AXS", OK: "AXS-USDT-SWAP"},
|
||||||
{Name: "AZTEC", BN: "", BG: "AZTECUSDT", HL: "AZTEC"},
|
{Name: "AZTEC", BN: "AZTECUSDT", BG: "AZTECUSDT", HL: "AZTEC", OK: "AZTEC-USDT-SWAP"},
|
||||||
{Name: "BABY", BN: "", BG: "BABYUSDT", HL: "BABY"},
|
{Name: "BABY", BN: "BABYUSDT", BG: "BABYUSDT", HL: "BABY", OK: "BABY-USDT-SWAP"},
|
||||||
{Name: "BANANA", BN: "", BG: "BANANAUSDT", HL: "BANANA"},
|
{Name: "BANANA", BN: "BANANAUSDT", BG: "BANANAUSDT", HL: "BANANA", OK: "BANANA-USDT-SWAP"},
|
||||||
{Name: "BCH", BN: "", BG: "BCHUSDT", HL: "BCH"},
|
{Name: "BCH", BN: "BCHUSDT", BG: "BCHUSDT", HL: "BCH", OK: "BCH-USDT-SWAP"},
|
||||||
{Name: "BERA", BN: "", BG: "BERAUSDT", HL: "BERA"},
|
{Name: "BERA", BN: "BERAUSDT", BG: "BERAUSDT", HL: "BERA", OK: "BERA-USDT-SWAP"},
|
||||||
{Name: "BIGTIME", BN: "", BG: "BIGTIMEUSDT", HL: "BIGTIME"},
|
{Name: "BIGTIME", BN: "BIGTIMEUSDT", BG: "BIGTIMEUSDT", HL: "BIGTIME", OK: "BIGTIME-USDT-SWAP"},
|
||||||
{Name: "BIO", BN: "", BG: "BIOUSDT", HL: "BIO"},
|
{Name: "BIO", BN: "BIOUSDT", BG: "BIOUSDT", HL: "BIO", OK: "BIO-USDT-SWAP"},
|
||||||
{Name: "BLUR", BN: "", BG: "BLURUSDT", HL: "BLUR"},
|
{Name: "BLUR", BN: "BLURUSDT", BG: "BLURUSDT", HL: "BLUR", OK: "BLUR-USDT-SWAP"},
|
||||||
{Name: "BNB", BN: "", BG: "BNBUSDT", HL: "BNB"},
|
{Name: "BNB", BN: "BNBUSDT", BG: "BNBUSDT", HL: "BNB", OK: "BNB-USDT-SWAP"},
|
||||||
{Name: "BNT", BN: "", BG: "BNTUSDT", HL: ""},
|
{Name: "BNT", BN: "BNTUSDT", BG: "BNTUSDT", HL: "", OK: "BNT-USDT-SWAP"},
|
||||||
{Name: "BOME", BN: "", BG: "BOMEUSDT", HL: "BOME"},
|
{Name: "BOME", BN: "BOMEUSDT", BG: "BOMEUSDT", HL: "BOME", OK: "BOME-USDT-SWAP"},
|
||||||
{Name: "BRETT", BN: "", BG: "BRETTUSDT", HL: "BRETT"},
|
{Name: "BRETT", BN: "BRETTUSDT", BG: "BRETTUSDT", HL: "BRETT", OK: "BRETT-USDT-SWAP"},
|
||||||
{Name: "BSV", BN: "", BG: "BSVUSDT", HL: "BSV"},
|
{Name: "BSV", BN: "BSVUSDT", BG: "BSVUSDT", HL: "BSV", OK: "BSV-USDT-SWAP"},
|
||||||
{Name: "BTC", BN: "", BG: "BTCUSDT", HL: "BTC"},
|
{Name: "BTC", BN: "BTCUSDT", BG: "BTCUSDT", HL: "BTC", OK: "BTC-USDT-SWAP"},
|
||||||
{Name: "CAKE", BN: "", BG: "CAKEUSDT", HL: "CAKE"},
|
{Name: "CAKE", BN: "CAKEUSDT", BG: "CAKEUSDT", HL: "CAKE", OK: "CAKE-USDT-SWAP"},
|
||||||
{Name: "CATI", BN: "", BG: "CATIUSDT", HL: ""},
|
{Name: "CATI", BN: "CATIUSDT", BG: "CATIUSDT", HL: "", OK: "CATI-USDT-SWAP"},
|
||||||
{Name: "CC", BN: "", BG: "CCUSDT", HL: "CC"},
|
{Name: "CC", BN: "CCUSDT", BG: "CCUSDT", HL: "CC", OK: "CC-USDT-SWAP"},
|
||||||
{Name: "CELO", BN: "", BG: "CELOUSDT", HL: "CELO"},
|
{Name: "CELO", BN: "CELOUSDT", BG: "CELOUSDT", HL: "CELO", OK: "CELO-USDT-SWAP"},
|
||||||
{Name: "CFX", BN: "", BG: "CFXUSDT", HL: "CFX"},
|
{Name: "CFX", BN: "CFXUSDT", BG: "CFXUSDT", HL: "CFX", OK: "CFX-USDT-SWAP"},
|
||||||
{Name: "CHILLGUY", BN: "", BG: "CHILLGUYUSDT", HL: "CHILLGUY"},
|
{Name: "CHILLGUY", BN: "CHILLGUYUSDT", BG: "CHILLGUYUSDT", HL: "CHILLGUY", OK: "CHILLGUY-USDT-SWAP"},
|
||||||
{Name: "CHIP", BN: "", BG: "CHIPUSDT", HL: "CHIP"},
|
{Name: "CHIP", BN: "CHIPUSDT", BG: "CHIPUSDT", HL: "CHIP", OK: "CHIP-USDT-SWAP"},
|
||||||
{Name: "COMP", BN: "", BG: "COMPUSDT", HL: "COMP"},
|
{Name: "COMP", BN: "COMPUSDT", BG: "COMPUSDT", HL: "COMP", OK: "COMP-USDT-SWAP"},
|
||||||
{Name: "CRV", BN: "", BG: "CRVUSDT", HL: "CRV"},
|
{Name: "CRV", BN: "CRVUSDT", BG: "CRVUSDT", HL: "CRV", OK: "CRV-USDT-SWAP"},
|
||||||
{Name: "CYBER", BN: "", BG: "CYBERUSDT", HL: ""},
|
{Name: "CYBER", BN: "CYBERUSDT", BG: "CYBERUSDT", HL: "", OK: "CYBER-USDT-SWAP"},
|
||||||
{Name: "DASH", BN: "", BG: "DASHUSDT", HL: "DASH"},
|
{Name: "DASH", BN: "DASHUSDT", BG: "DASHUSDT", HL: "DASH", OK: "DASH-USDT-SWAP"},
|
||||||
{Name: "DOOD", BN: "", BG: "DOODUSDT", HL: "DOOD"},
|
{Name: "DOOD", BN: "DOODUSDT", BG: "DOODUSDT", HL: "DOOD", OK: "DOOD-USDT-SWAP"},
|
||||||
{Name: "DOT", BN: "", BG: "DOTUSDT", HL: "DOT"},
|
{Name: "DOT", BN: "DOTUSDT", BG: "DOTUSDT", HL: "DOT", OK: "DOT-USDT-SWAP"},
|
||||||
{Name: "DYDX", BN: "", BG: "DYDXUSDT", HL: "DYDX"},
|
{Name: "DYDX", BN: "DYDXUSDT", BG: "DYDXUSDT", HL: "DYDX", OK: "DYDX-USDT-SWAP"},
|
||||||
{Name: "DYM", BN: "", BG: "DYMUSDT", HL: "DYM"},
|
{Name: "DYM", BN: "DYMUSDT", BG: "DYMUSDT", HL: "DYM", OK: "DYM-USDT-SWAP"},
|
||||||
{Name: "EIGEN", BN: "", BG: "EIGENUSDT", HL: "EIGEN"},
|
{Name: "EIGEN", BN: "EIGENUSDT", BG: "EIGENUSDT", HL: "EIGEN", OK: "EIGEN-USDT-SWAP"},
|
||||||
{Name: "ENA", BN: "", BG: "ENAUSDT", HL: "ENA"},
|
{Name: "ENA", BN: "ENAUSDT", BG: "ENAUSDT", HL: "ENA", OK: "ENA-USDT-SWAP"},
|
||||||
{Name: "ENS", BN: "", BG: "ENSUSDT", HL: "ENS"},
|
{Name: "ENS", BN: "ENSUSDT", BG: "ENSUSDT", HL: "ENS", OK: "ENS-USDT-SWAP"},
|
||||||
{Name: "ETC", BN: "", BG: "ETCUSDT", HL: "ETC"},
|
{Name: "ETC", BN: "ETCUSDT", BG: "ETCUSDT", HL: "ETC", OK: "ETC-USDT-SWAP"},
|
||||||
{Name: "ETH", BN: "", BG: "ETHUSDT", HL: "ETH"},
|
{Name: "ETH", BN: "ETHUSDT", BG: "ETHUSDT", HL: "ETH", OK: "ETH-USDT-SWAP"},
|
||||||
{Name: "ETHFI", BN: "", BG: "ETHFIUSDT", HL: "ETHFI"},
|
{Name: "ETHFI", BN: "ETHFIUSDT", BG: "ETHFIUSDT", HL: "ETHFI", OK: "ETHFI-USDT-SWAP"},
|
||||||
{Name: "FARTCOIN", BN: "", BG: "FARTCOINUSDT", HL: "FARTCOIN"},
|
{Name: "FARTCOIN", BN: "FARTCOINUSDT", BG: "FARTCOINUSDT", HL: "FARTCOIN", OK: "FARTCOIN-USDT-SWAP"},
|
||||||
{Name: "FET", BN: "", BG: "FETUSDT", HL: "FET"},
|
{Name: "FET", BN: "FETUSDT", BG: "FETUSDT", HL: "FET", OK: "FET-USDT-SWAP"},
|
||||||
{Name: "FIL", BN: "", BG: "FILUSDT", HL: "FIL"},
|
{Name: "FIL", BN: "FILUSDT", BG: "FILUSDT", HL: "FIL", OK: "FIL-USDT-SWAP"},
|
||||||
{Name: "FOGO", BN: "", BG: "FOGOUSDT", HL: "FOGO"},
|
{Name: "FOGO", BN: "FOGOUSDT", BG: "FOGOUSDT", HL: "FOGO", OK: "FOGO-USDT-SWAP"},
|
||||||
{Name: "GALA", BN: "", BG: "GALAUSDT", HL: "GALA"},
|
{Name: "GALA", BN: "GALAUSDT", BG: "GALAUSDT", HL: "GALA", OK: "GALA-USDT-SWAP"},
|
||||||
{Name: "GAS", BN: "", BG: "GASUSDT", HL: "GAS"},
|
{Name: "GAS", BN: "GASUSDT", BG: "GASUSDT", HL: "GAS", OK: "GAS-USDT-SWAP"},
|
||||||
{Name: "GMT", BN: "", BG: "GMTUSDT", HL: "GMT"},
|
{Name: "GMT", BN: "GMTUSDT", BG: "GMTUSDT", HL: "GMT", OK: "GMT-USDT-SWAP"},
|
||||||
{Name: "GMX", BN: "", BG: "GMXUSDT", HL: "GMX"},
|
{Name: "GMX", BN: "GMXUSDT", BG: "GMXUSDT", HL: "GMX", OK: "GMX-USDT-SWAP"},
|
||||||
{Name: "GOAT", BN: "", BG: "GOATUSDT", HL: "GOAT"},
|
{Name: "GOAT", BN: "GOATUSDT", BG: "GOATUSDT", HL: "GOAT", OK: "GOAT-USDT-SWAP"},
|
||||||
{Name: "GRASS", BN: "", BG: "GRASSUSDT", HL: "GRASS"},
|
{Name: "GRASS", BN: "GRASSUSDT", BG: "GRASSUSDT", HL: "GRASS", OK: "GRASS-USDT-SWAP"},
|
||||||
{Name: "GRIFFAIN", BN: "", BG: "GRIFFAINUSDT", HL: "GRIFFAIN"},
|
{Name: "GRIFFAIN", BN: "GRIFFAINUSDT", BG: "GRIFFAINUSDT", HL: "GRIFFAIN", OK: "GRIFFAIN-USDT-SWAP"},
|
||||||
{Name: "HBAR", BN: "", BG: "HBARUSDT", HL: "HBAR"},
|
{Name: "HBAR", BN: "HBARUSDT", BG: "HBARUSDT", HL: "HBAR", OK: "HBAR-USDT-SWAP"},
|
||||||
{Name: "HYPE", BN: "", BG: "HYPEUSDT", HL: "HYPE"},
|
{Name: "HYPE", BN: "HYPEUSDT", BG: "HYPEUSDT", HL: "HYPE", OK: "HYPE-USDT-SWAP"},
|
||||||
{Name: "HYPER", BN: "", BG: "HYPERUSDT", HL: "HYPER"},
|
{Name: "HYPER", BN: "HYPERUSDT", BG: "HYPERUSDT", HL: "HYPER", OK: "HYPER-USDT-SWAP"},
|
||||||
{Name: "ICP", BN: "", BG: "ICPUSDT", HL: "ICP"},
|
{Name: "ICP", BN: "ICPUSDT", BG: "ICPUSDT", HL: "ICP", OK: "ICP-USDT-SWAP"},
|
||||||
{Name: "ILV", BN: "", BG: "ILVUSDT", HL: ""},
|
{Name: "ILV", BN: "ILVUSDT", BG: "ILVUSDT", HL: "", OK: "ILV-USDT-SWAP"},
|
||||||
{Name: "IMX", BN: "", BG: "IMXUSDT", HL: "IMX"},
|
{Name: "IMX", BN: "IMXUSDT", BG: "IMXUSDT", HL: "IMX", OK: "IMX-USDT-SWAP"},
|
||||||
{Name: "INIT", BN: "", BG: "INITUSDT", HL: "INIT"},
|
{Name: "INIT", BN: "INITUSDT", BG: "INITUSDT", HL: "INIT", OK: "INIT-USDT-SWAP"},
|
||||||
{Name: "INJ", BN: "", BG: "INJUSDT", HL: "INJ"},
|
{Name: "INJ", BN: "INJUSDT", BG: "INJUSDT", HL: "INJ", OK: "INJ-USDT-SWAP"},
|
||||||
{Name: "IO", BN: "", BG: "IOUSDT", HL: "IO"},
|
{Name: "IO", BN: "IOUSDT", BG: "IOUSDT", HL: "IO", OK: "IO-USDT-SWAP"},
|
||||||
{Name: "IOTA", BN: "", BG: "IOTAUSDT", HL: "IOTA"},
|
{Name: "IOTA", BN: "IOTAUSDT", BG: "IOTAUSDT", HL: "IOTA", OK: "IOTA-USDT-SWAP"},
|
||||||
{Name: "IP", BN: "", BG: "IPUSDT", HL: "IP"},
|
{Name: "IP", BN: "IPUSDT", BG: "IPUSDT", HL: "IP", OK: "IP-USDT-SWAP"},
|
||||||
{Name: "JTO", BN: "", BG: "JTOUSDT", HL: "JTO"},
|
{Name: "JTO", BN: "JTOUSDT", BG: "JTOUSDT", HL: "JTO", OK: "JTO-USDT-SWAP"},
|
||||||
{Name: "JUP", BN: "", BG: "JUPUSDT", HL: "JUP"},
|
{Name: "JUP", BN: "JUPUSDT", BG: "JUPUSDT", HL: "JUP", OK: "JUP-USDT-SWAP"},
|
||||||
{Name: "KAITO", BN: "", BG: "KAITOUSDT", HL: "KAITO"},
|
{Name: "KAITO", BN: "KAITOUSDT", BG: "KAITOUSDT", HL: "KAITO", OK: "KAITO-USDT-SWAP"},
|
||||||
{Name: "KAS", BN: "", BG: "KASUSDT", HL: "KAS"},
|
{Name: "KAS", BN: "KASUSDT", BG: "KASUSDT", HL: "KAS", OK: "KAS-USDT-SWAP"},
|
||||||
{Name: "LAYER", BN: "", BG: "LAYERUSDT", HL: "LAYER"},
|
{Name: "LAYER", BN: "LAYERUSDT", BG: "LAYERUSDT", HL: "LAYER", OK: "LAYER-USDT-SWAP"},
|
||||||
{Name: "LDO", BN: "", BG: "LDOUSDT", HL: "LDO"},
|
{Name: "LDO", BN: "LDOUSDT", BG: "LDOUSDT", HL: "LDO", OK: "LDO-USDT-SWAP"},
|
||||||
{Name: "LINEA", BN: "", BG: "LINEAUSDT", HL: "LINEA"},
|
{Name: "LINEA", BN: "LINEAUSDT", BG: "LINEAUSDT", HL: "LINEA", OK: "LINEA-USDT-SWAP"},
|
||||||
{Name: "LISTA", BN: "", BG: "LISTAUSDT", HL: ""},
|
{Name: "LISTA", BN: "LISTAUSDT", BG: "LISTAUSDT", HL: "", OK: "LISTA-USDT-SWAP"},
|
||||||
{Name: "LIT", BN: "", BG: "LITUSDT", HL: "LIT"},
|
{Name: "LIT", BN: "LITUSDT", BG: "LITUSDT", HL: "LIT", OK: "LIT-USDT-SWAP"},
|
||||||
{Name: "LTC", BN: "", BG: "LTCUSDT", HL: "LTC"},
|
{Name: "LTC", BN: "LTCUSDT", BG: "LTCUSDT", HL: "LTC", OK: "LTC-USDT-SWAP"},
|
||||||
{Name: "MANTA", BN: "", BG: "MANTAUSDT", HL: "MANTA"},
|
{Name: "MANTA", BN: "MANTAUSDT", BG: "MANTAUSDT", HL: "MANTA", OK: "MANTA-USDT-SWAP"},
|
||||||
{Name: "MAV", BN: "", BG: "MAVUSDT", HL: "MAV"},
|
{Name: "MAV", BN: "MAVUSDT", BG: "MAVUSDT", HL: "MAV", OK: "MAV-USDT-SWAP"},
|
||||||
{Name: "ME", BN: "", BG: "MEUSDT", HL: "ME"},
|
{Name: "ME", BN: "MEUSDT", BG: "MEUSDT", HL: "ME", OK: "ME-USDT-SWAP"},
|
||||||
{Name: "MEGA", BN: "", BG: "MEGAUSDT", HL: "MEGA"},
|
{Name: "MEGA", BN: "MEGAUSDT", BG: "MEGAUSDT", HL: "MEGA", OK: "MEGA-USDT-SWAP"},
|
||||||
{Name: "MELANIA", BN: "", BG: "MELANIAUSDT", HL: "MELANIA"},
|
{Name: "MELANIA", BN: "MELANIAUSDT", BG: "MELANIAUSDT", HL: "MELANIA", OK: "MELANIA-USDT-SWAP"},
|
||||||
{Name: "MEME", BN: "", BG: "MEMEUSDT", HL: "MEME"},
|
{Name: "MEME", BN: "MEMEUSDT", BG: "MEMEUSDT", HL: "MEME", OK: "MEME-USDT-SWAP"},
|
||||||
{Name: "MERL", BN: "", BG: "MERLUSDT", HL: "MERL"},
|
{Name: "MERL", BN: "MERLUSDT", BG: "MERLUSDT", HL: "MERL", OK: "MERL-USDT-SWAP"},
|
||||||
{Name: "MET", BN: "", BG: "METUSDT", HL: "MET"},
|
{Name: "MET", BN: "METUSDT", BG: "METUSDT", HL: "MET", OK: "MET-USDT-SWAP"},
|
||||||
{Name: "MINA", BN: "", BG: "MINAUSDT", HL: "MINA"},
|
{Name: "MINA", BN: "MINAUSDT", BG: "MINAUSDT", HL: "MINA", OK: "MINA-USDT-SWAP"},
|
||||||
{Name: "MON", BN: "", BG: "MONUSDT", HL: "MON"},
|
{Name: "MON", BN: "MONUSDT", BG: "MONUSDT", HL: "MON", OK: "MON-USDT-SWAP"},
|
||||||
{Name: "MOODENG", BN: "", BG: "MOODENGUSDT", HL: "MOODENG"},
|
{Name: "MOODENG", BN: "MOODENGUSDT", BG: "MOODENGUSDT", HL: "MOODENG", OK: "MOODENG-USDT-SWAP"},
|
||||||
{Name: "MORPHO", BN: "", BG: "MORPHOUSDT", HL: "MORPHO"},
|
{Name: "MORPHO", BN: "MORPHOUSDT", BG: "MORPHOUSDT", HL: "MORPHO", OK: "MORPHO-USDT-SWAP"},
|
||||||
{Name: "MOVE", BN: "", BG: "MOVEUSDT", HL: "MOVE"},
|
{Name: "MOVE", BN: "MOVEUSDT", BG: "MOVEUSDT", HL: "MOVE", OK: "MOVE-USDT-SWAP"},
|
||||||
{Name: "NEAR", BN: "", BG: "NEARUSDT", HL: "NEAR"},
|
{Name: "NEAR", BN: "NEARUSDT", BG: "NEARUSDT", HL: "NEAR", OK: "NEAR-USDT-SWAP"},
|
||||||
{Name: "NEO", BN: "", BG: "NEOUSDT", HL: "NEO"},
|
{Name: "NEO", BN: "NEOUSDT", BG: "NEOUSDT", HL: "NEO", OK: "NEO-USDT-SWAP"},
|
||||||
{Name: "NIL", BN: "", BG: "NILUSDT", HL: "NIL"},
|
{Name: "NIL", BN: "NILUSDT", BG: "NILUSDT", HL: "NIL", OK: "NIL-USDT-SWAP"},
|
||||||
{Name: "NOT", BN: "", BG: "NOTUSDT", HL: "NOT"},
|
{Name: "NOT", BN: "NOTUSDT", BG: "NOTUSDT", HL: "NOT", OK: "NOT-USDT-SWAP"},
|
||||||
{Name: "NXPC", BN: "", BG: "NXPCUSDT", HL: "NXPC"},
|
{Name: "NXPC", BN: "NXPCUSDT", BG: "NXPCUSDT", HL: "NXPC", OK: "NXPC-USDT-SWAP"},
|
||||||
{Name: "OGN", BN: "", BG: "OGNUSDT", HL: ""},
|
{Name: "OGN", BN: "OGNUSDT", BG: "OGNUSDT", HL: "", OK: "OGN-USDT-SWAP"},
|
||||||
{Name: "ORDI", BN: "", BG: "ORDIUSDT", HL: "ORDI"},
|
{Name: "ORDI", BN: "ORDIUSDT", BG: "ORDIUSDT", HL: "ORDI", OK: "ORDI-USDT-SWAP"},
|
||||||
{Name: "PAXG", BN: "", BG: "PAXGUSDT", HL: "PAXG"},
|
{Name: "PAXG", BN: "PAXGUSDT", BG: "PAXGUSDT", HL: "PAXG", OK: "PAXG-USDT-SWAP"},
|
||||||
{Name: "PENDLE", BN: "", BG: "PENDLEUSDT", HL: "PENDLE"},
|
{Name: "PENDLE", BN: "PENDLEUSDT", BG: "PENDLEUSDT", HL: "PENDLE", OK: "PENDLE-USDT-SWAP"},
|
||||||
{Name: "PENGU", BN: "", BG: "PENGUUSDT", HL: "PENGU"},
|
{Name: "PENGU", BN: "PENGUUSDT", BG: "PENGUUSDT", HL: "PENGU", OK: "PENGU-USDT-SWAP"},
|
||||||
{Name: "PEOPLE", BN: "", BG: "PEOPLEUSDT", HL: "PEOPLE"},
|
{Name: "PEOPLE", BN: "PEOPLEUSDT", BG: "PEOPLEUSDT", HL: "PEOPLE", OK: "PEOPLE-USDT-SWAP"},
|
||||||
{Name: "PIXEL", BN: "", BG: "PIXELUSDT", HL: ""},
|
{Name: "PIXEL", BN: "PIXELUSDT", BG: "PIXELUSDT", HL: "", OK: "PIXEL-USDT-SWAP"},
|
||||||
{Name: "PNUT", BN: "", BG: "PNUTUSDT", HL: "PNUT"},
|
{Name: "PNUT", BN: "PNUTUSDT", BG: "PNUTUSDT", HL: "PNUT", OK: "PNUT-USDT-SWAP"},
|
||||||
{Name: "POL", BN: "", BG: "POLUSDT", HL: "POL"},
|
{Name: "POL", BN: "POLUSDT", BG: "POLUSDT", HL: "POL", OK: "POL-USDT-SWAP"},
|
||||||
{Name: "POLYX", BN: "", BG: "POLYXUSDT", HL: "POLYX"},
|
{Name: "POLYX", BN: "POLYXUSDT", BG: "POLYXUSDT", HL: "POLYX", OK: "POLYX-USDT-SWAP"},
|
||||||
{Name: "POPCAT", BN: "", BG: "POPCATUSDT", HL: "POPCAT"},
|
{Name: "POPCAT", BN: "POPCATUSDT", BG: "POPCATUSDT", HL: "POPCAT", OK: "POPCAT-USDT-SWAP"},
|
||||||
{Name: "PROVE", BN: "", BG: "PROVEUSDT", HL: "PROVE"},
|
{Name: "PROVE", BN: "PROVEUSDT", BG: "PROVEUSDT", HL: "PROVE", OK: "PROVE-USDT-SWAP"},
|
||||||
{Name: "PUMP", BN: "", BG: "PUMPUSDT", HL: "PUMP"},
|
{Name: "PUMP", BN: "PUMPUSDT", BG: "PUMPUSDT", HL: "PUMP", OK: "PUMP-USDT-SWAP"},
|
||||||
{Name: "PYTH", BN: "", BG: "PYTHUSDT", HL: "PYTH"},
|
{Name: "PYTH", BN: "PYTHUSDT", BG: "PYTHUSDT", HL: "PYTH", OK: "PYTH-USDT-SWAP"},
|
||||||
{Name: "RENDER", BN: "", BG: "RENDERUSDT", HL: "RENDER"},
|
{Name: "RENDER", BN: "RENDERUSDT", BG: "RENDERUSDT", HL: "RENDER", OK: "RENDER-USDT-SWAP"},
|
||||||
{Name: "RESOLV", BN: "", BG: "RESOLVUSDT", HL: "RESOLV"},
|
{Name: "RESOLV", BN: "RESOLVUSDT", BG: "RESOLVUSDT", HL: "RESOLV", OK: "RESOLV-USDT-SWAP"},
|
||||||
{Name: "REZ", BN: "", BG: "REZUSDT", HL: "REZ"},
|
{Name: "REZ", BN: "REZUSDT", BG: "REZUSDT", HL: "REZ", OK: "REZ-USDT-SWAP"},
|
||||||
{Name: "RSR", BN: "", BG: "RSRUSDT", HL: "RSR"},
|
{Name: "RSR", BN: "RSRUSDT", BG: "RSRUSDT", HL: "RSR", OK: "RSR-USDT-SWAP"},
|
||||||
{Name: "RUNE", BN: "", BG: "RUNEUSDT", HL: "RUNE"},
|
{Name: "RUNE", BN: "RUNEUSDT", BG: "RUNEUSDT", HL: "RUNE", OK: "RUNE-USDT-SWAP"},
|
||||||
{Name: "S", BN: "", BG: "SUSDT", HL: "S"},
|
{Name: "S", BN: "SUSDT", BG: "SUSDT", HL: "S", OK: "S-USDT-SWAP"},
|
||||||
{Name: "SAGA", BN: "", BG: "SAGAUSDT", HL: "SAGA"},
|
{Name: "SAGA", BN: "SAGAUSDT", BG: "SAGAUSDT", HL: "SAGA", OK: "SAGA-USDT-SWAP"},
|
||||||
{Name: "SAND", BN: "", BG: "SANDUSDT", HL: "SAND"},
|
{Name: "SAND", BN: "SANDUSDT", BG: "SANDUSDT", HL: "SAND", OK: "SAND-USDT-SWAP"},
|
||||||
{Name: "SEI", BN: "", BG: "SEIUSDT", HL: "SEI"},
|
{Name: "SEI", BN: "SEIUSDT", BG: "SEIUSDT", HL: "SEI", OK: "SEI-USDT-SWAP"},
|
||||||
{Name: "SKR", BN: "", BG: "SKRUSDT", HL: "SKR"},
|
{Name: "SKR", BN: "SKRUSDT", BG: "SKRUSDT", HL: "SKR", OK: "SKR-USDT-SWAP"},
|
||||||
{Name: "SKY", BN: "", BG: "SKYUSDT", HL: "SKY"},
|
{Name: "SKY", BN: "SKYUSDT", BG: "SKYUSDT", HL: "SKY", OK: "SKY-USDT-SWAP"},
|
||||||
{Name: "SNX", BN: "", BG: "SNXUSDT", HL: "SNX"},
|
{Name: "SNX", BN: "SNXUSDT", BG: "SNXUSDT", HL: "SNX", OK: "SNX-USDT-SWAP"},
|
||||||
{Name: "SOL", BN: "", BG: "SOLUSDT", HL: "SOL"},
|
{Name: "SOL", BN: "SOLUSDT", BG: "SOLUSDT", HL: "SOL", OK: "SOL-USDT-SWAP"},
|
||||||
{Name: "SOPH", BN: "", BG: "SOPHUSDT", HL: "SOPH"},
|
{Name: "SOPH", BN: "SOPHUSDT", BG: "SOPHUSDT", HL: "SOPH", OK: "SOPH-USDT-SWAP"},
|
||||||
{Name: "SPX", BN: "", BG: "SPXUSDT", HL: "SPX"},
|
{Name: "SPX", BN: "SPXUSDT", BG: "SPXUSDT", HL: "SPX", OK: "SPX-USDT-SWAP"},
|
||||||
{Name: "STABLE", BN: "", BG: "STABLEUSDT", HL: "STABLE"},
|
{Name: "STABLE", BN: "STABLEUSDT", BG: "STABLEUSDT", HL: "STABLE", OK: "STABLE-USDT-SWAP"},
|
||||||
{Name: "STG", BN: "", BG: "STGUSDT", HL: ""},
|
{Name: "STG", BN: "STGUSDT", BG: "STGUSDT", HL: "", OK: "STG-USDT-SWAP"},
|
||||||
{Name: "STRK", BN: "", BG: "STRKUSDT", HL: "STRK"},
|
{Name: "STRK", BN: "STRKUSDT", BG: "STRKUSDT", HL: "STRK", OK: "STRK-USDT-SWAP"},
|
||||||
{Name: "STX", BN: "", BG: "STXUSDT", HL: "STX"},
|
{Name: "STX", BN: "STXUSDT", BG: "STXUSDT", HL: "STX", OK: "STX-USDT-SWAP"},
|
||||||
{Name: "SUI", BN: "", BG: "SUIUSDT", HL: "SUI"},
|
{Name: "SUI", BN: "SUIUSDT", BG: "SUIUSDT", HL: "SUI", OK: "SUI-USDT-SWAP"},
|
||||||
{Name: "SUPER", BN: "", BG: "SUPERUSDT", HL: "SUPER"},
|
{Name: "SUPER", BN: "SUPERUSDT", BG: "SUPERUSDT", HL: "SUPER", OK: "SUPER-USDT-SWAP"},
|
||||||
{Name: "SUSHI", BN: "", BG: "SUSHIUSDT", HL: "SUSHI"},
|
{Name: "SUSHI", BN: "SUSHIUSDT", BG: "SUSHIUSDT", HL: "SUSHI", OK: "SUSHI-USDT-SWAP"},
|
||||||
{Name: "SYRUP", BN: "", BG: "SYRUPUSDT", HL: "SYRUP"},
|
{Name: "SYRUP", BN: "SYRUPUSDT", BG: "SYRUPUSDT", HL: "SYRUP", OK: "SYRUP-USDT-SWAP"},
|
||||||
{Name: "TAO", BN: "", BG: "TAOUSDT", HL: "TAO"},
|
{Name: "TAO", BN: "TAOUSDT", BG: "TAOUSDT", HL: "TAO", OK: "TAO-USDT-SWAP"},
|
||||||
{Name: "TIA", BN: "", BG: "TIAUSDT", HL: "TIA"},
|
{Name: "TIA", BN: "TIAUSDT", BG: "TIAUSDT", HL: "TIA", OK: "TIA-USDT-SWAP"},
|
||||||
{Name: "TNSR", BN: "", BG: "TNSRUSDT", HL: "TNSR"},
|
{Name: "TNSR", BN: "TNSRUSDT", BG: "TNSRUSDT", HL: "TNSR", OK: "TNSR-USDT-SWAP"},
|
||||||
{Name: "TON", BN: "", BG: "TONUSDT", HL: "TON"},
|
{Name: "TON", BN: "TONUSDT", BG: "TONUSDT", HL: "TON", OK: "TON-USDT-SWAP"},
|
||||||
{Name: "TRB", BN: "", BG: "TRBUSDT", HL: "TRB"},
|
{Name: "TRB", BN: "TRBUSDT", BG: "TRBUSDT", HL: "TRB", OK: "TRB-USDT-SWAP"},
|
||||||
{Name: "TRUMP", BN: "", BG: "TRUMPUSDT", HL: "TRUMP"},
|
{Name: "TRUMP", BN: "TRUMPUSDT", BG: "TRUMPUSDT", HL: "TRUMP", OK: "TRUMP-USDT-SWAP"},
|
||||||
{Name: "TRX", BN: "", BG: "TRXUSDT", HL: "TRX"},
|
{Name: "TRX", BN: "TRXUSDT", BG: "TRXUSDT", HL: "TRX", OK: "TRX-USDT-SWAP"},
|
||||||
{Name: "TURBO", BN: "", BG: "TURBOUSDT", HL: "TURBO"},
|
{Name: "TURBO", BN: "TURBOUSDT", BG: "TURBOUSDT", HL: "TURBO", OK: "TURBO-USDT-SWAP"},
|
||||||
{Name: "UMA", BN: "", BG: "UMAUSDT", HL: "UMA"},
|
{Name: "UMA", BN: "UMAUSDT", BG: "UMAUSDT", HL: "UMA", OK: "UMA-USDT-SWAP"},
|
||||||
{Name: "UNI", BN: "", BG: "UNIUSDT", HL: "UNI"},
|
{Name: "UNI", BN: "UNIUSDT", BG: "UNIUSDT", HL: "UNI", OK: "UNI-USDT-SWAP"},
|
||||||
{Name: "USUAL", BN: "", BG: "USUALUSDT", HL: "USUAL"},
|
{Name: "USUAL", BN: "USUALUSDT", BG: "USUALUSDT", HL: "USUAL", OK: "USUAL-USDT-SWAP"},
|
||||||
{Name: "VIRTUAL", BN: "", BG: "VIRTUALUSDT", HL: "VIRTUAL"},
|
{Name: "VIRTUAL", BN: "VIRTUALUSDT", BG: "VIRTUALUSDT", HL: "VIRTUAL", OK: "VIRTUAL-USDT-SWAP"},
|
||||||
{Name: "VVV", BN: "", BG: "VVVUSDT", HL: "VVV"},
|
{Name: "VVV", BN: "VVVUSDT", BG: "VVVUSDT", HL: "VVV", OK: "VVV-USDT-SWAP"},
|
||||||
{Name: "W", BN: "", BG: "WUSDT", HL: "W"},
|
{Name: "W", BN: "WUSDT", BG: "WUSDT", HL: "W", OK: "W-USDT-SWAP"},
|
||||||
{Name: "WCT", BN: "", BG: "WCTUSDT", HL: "WCT"},
|
{Name: "WCT", BN: "WCTUSDT", BG: "WCTUSDT", HL: "WCT", OK: "WCT-USDT-SWAP"},
|
||||||
{Name: "WLD", BN: "", BG: "WLDUSDT", HL: "WLD"},
|
{Name: "WLD", BN: "WLDUSDT", BG: "WLDUSDT", HL: "WLD", OK: "WLD-USDT-SWAP"},
|
||||||
{Name: "WLFI", BN: "", BG: "WLFIUSDT", HL: "WLFI"},
|
{Name: "WLFI", BN: "WLFIUSDT", BG: "WLFIUSDT", HL: "WLFI", OK: "WLFI-USDT-SWAP"},
|
||||||
{Name: "XAI", BN: "", BG: "XAIUSDT", HL: "XAI"},
|
{Name: "XAI", BN: "XAIUSDT", BG: "XAIUSDT", HL: "XAI", OK: "XAI-USDT-SWAP"},
|
||||||
{Name: "XLM", BN: "", BG: "XLMUSDT", HL: "XLM"},
|
{Name: "XLM", BN: "XLMUSDT", BG: "XLMUSDT", HL: "XLM", OK: "XLM-USDT-SWAP"},
|
||||||
{Name: "XMR", BN: "", BG: "XMRUSDT", HL: "XMR"},
|
{Name: "XMR", BN: "XMRUSDT", BG: "XMRUSDT", HL: "XMR", OK: "XMR-USDT-SWAP"},
|
||||||
{Name: "XPL", BN: "", BG: "XPLUSDT", HL: "XPL"},
|
{Name: "XPL", BN: "XPLUSDT", BG: "XPLUSDT", HL: "XPL", OK: "XPL-USDT-SWAP"},
|
||||||
{Name: "XRP", BN: "", BG: "XRPUSDT", HL: "XRP"},
|
{Name: "XRP", BN: "XRPUSDT", BG: "XRPUSDT", HL: "XRP", OK: "XRP-USDT-SWAP"},
|
||||||
{Name: "YGG", BN: "", BG: "YGGUSDT", HL: "YGG"},
|
{Name: "YGG", BN: "YGGUSDT", BG: "YGGUSDT", HL: "YGG", OK: "YGG-USDT-SWAP"},
|
||||||
{Name: "ZEC", BN: "", BG: "ZECUSDT", HL: "ZEC"},
|
{Name: "ZEC", BN: "ZECUSDT", BG: "ZECUSDT", HL: "ZEC", OK: "ZEC-USDT-SWAP"},
|
||||||
{Name: "ZEN", BN: "", BG: "ZENUSDT", HL: "ZEN"},
|
{Name: "ZEN", BN: "ZENUSDT", BG: "ZENUSDT", HL: "ZEN", OK: "ZEN-USDT-SWAP"},
|
||||||
{Name: "ZETA", BN: "", BG: "ZETAUSDT", HL: "ZETA"},
|
{Name: "ZETA", BN: "ZETAUSDT", BG: "ZETAUSDT", HL: "ZETA", OK: "ZETA-USDT-SWAP"},
|
||||||
{Name: "ZK", BN: "", BG: "ZKUSDT", HL: "ZK"},
|
{Name: "ZK", BN: "ZKUSDT", BG: "ZKUSDT", HL: "ZK", OK: "ZK-USDT-SWAP"},
|
||||||
{Name: "ZORA", BN: "", BG: "ZORAUSDT", HL: "ZORA"},
|
{Name: "ZORA", BN: "ZORAUSDT", BG: "ZORAUSDT", HL: "ZORA", OK: "ZORA-USDT-SWAP"},
|
||||||
{Name: "ZRO", BN: "", BG: "ZROUSDT", HL: "ZRO"},
|
{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).
|
// netProfit calculates net profit % after fees for a complete round trip (entry + exit).
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ if [ "$CLEAN_DB" = true ]; then
|
|||||||
fi
|
fi
|
||||||
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
|
NEED_BUILD=false
|
||||||
if [ ! -x "$BIN" ]; then
|
if [ ! -x "$BIN" ]; then
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -1277,12 +1276,11 @@ func (t *Trader) placeOrder(leg *PositionLeg, side string, store *PriceStore) (s
|
|||||||
leg.OrderID = resp
|
leg.OrderID = resp
|
||||||
log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr)
|
log.Printf("[ExRes] HL %s %s: size=%s", side, leg.Coin, szStr)
|
||||||
|
|
||||||
// Parse actual fill price, OID, and estimate fee from HL response
|
// Parse actual fill price from HL response
|
||||||
fillPrice, _, oid, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
||||||
if parseErr == nil && fillPrice > 0 {
|
if parseErr == nil && fillPrice > 0 {
|
||||||
leg.EntryPrice = fillPrice
|
leg.EntryPrice = fillPrice
|
||||||
leg.OrderID = strconv.FormatInt(oid, 10)
|
log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
|
||||||
log.Printf("[Fill] HL %s %s: actual fillPrice=%.6f oid=%d", side, leg.Coin, fillPrice, oid)
|
|
||||||
}
|
}
|
||||||
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid])
|
fee, fetchErr := t.hyperliquid.EstimateFeeFromResponse(resp, takerFees[ExHyperLiquid])
|
||||||
if fetchErr != nil {
|
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)
|
log.Printf("[ExRes] HL close %s %s: size=%s → response=%s", side, leg.Coin, leg.Size, resp)
|
||||||
leg.OrderID = resp
|
leg.OrderID = resp
|
||||||
|
|
||||||
// Parse actual fill price and OID from HL close response
|
// Parse actual fill price from HL close response
|
||||||
fillPrice, _, oid, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(resp)
|
||||||
if parseErr == nil && fillPrice > 0 {
|
if parseErr == nil && fillPrice > 0 {
|
||||||
leg.ExitPrice = fillPrice
|
leg.ExitPrice = fillPrice
|
||||||
leg.OrderID = strconv.FormatInt(oid, 10)
|
log.Printf("[Fill] HL close %s: actual exitPrice=%.6f", leg.Coin, fillPrice)
|
||||||
log.Printf("[Fill] HL close %s: actual exitPrice=%.6f oid=%d", leg.Coin, fillPrice, oid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
leg.Closed = true
|
leg.Closed = true
|
||||||
@@ -1759,12 +1756,11 @@ func (t *Trader) placeOrderAt(leg *PositionLeg, side string, store *PriceStore,
|
|||||||
}
|
}
|
||||||
leg.OrderID = oid
|
leg.OrderID = oid
|
||||||
|
|
||||||
// Parse actual fill price and OID from HL response
|
// Parse actual fill price from HL response
|
||||||
fillPrice, _, oidNum, parseErr := t.hyperliquid.ParseFillFromResponse(oid)
|
fillPrice, _, parseErr := t.hyperliquid.ParseFillFromResponse(oid)
|
||||||
if parseErr == nil && fillPrice > 0 {
|
if parseErr == nil && fillPrice > 0 {
|
||||||
leg.EntryPrice = fillPrice
|
leg.EntryPrice = fillPrice
|
||||||
leg.OrderID = strconv.FormatInt(oidNum, 10)
|
log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f", side, leg.Coin, fillPrice)
|
||||||
log.Printf("[Fill] HL scale %s %s: actual fillPrice=%.6f oid=%d", side, leg.Coin, fillPrice, oidNum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate fee from HL response
|
// Estimate fee from HL response
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type TrackedCoin struct {
|
|||||||
BN string // Binance symbol (BTCUSDT)
|
BN string // Binance symbol (BTCUSDT)
|
||||||
BG string // Bitget symbol (BTCUSDT)
|
BG string // Bitget symbol (BTCUSDT)
|
||||||
HL string // HyperLiquid symbol (BTC)
|
HL string // HyperLiquid symbol (BTC)
|
||||||
|
OK string // OKX symbol (BTC-USDT-SWAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PriceTick holds a price update with optional bid/ask.
|
// PriceTick holds a price update with optional bid/ask.
|
||||||
|
|||||||
Reference in New Issue
Block a user