feat: 趋势过滤信号记录系统 + 实时涨跌方向判断
- 新增 TrendFilter 信号记录(enter/exit),按完整信号和高分信号两档分类 - 信号持久化到 data/trend_signals_cache.json,开机自动恢复 - 新增 /api/trend-signals API + SSE trend_signal 实时广播 - 前端新增完整信号卡片和高分信号卡片,移除旧趋势检测卡片 - 评分加入 1h 涨跌方向和实时 drift 惩罚,下跌币不触发信号 - OKX 交易所支持(累积变动、动量、趋势检测) - 修复 trend_filter.go 编译错误 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
73dac50a36
commit
559d7bb870
@@ -51,7 +51,7 @@ type TrendEntry struct {
|
||||
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 %
|
||||
BGChange float64 `json:"bg_change"` // 60s change %
|
||||
HLChange float64 `json:"hl_change"`
|
||||
BNChange float64 `json:"bn_change"`
|
||||
OKXChange float64 `json:"okx_change"`
|
||||
@@ -72,6 +72,7 @@ type trendCoinState struct {
|
||||
state TrendState
|
||||
direction TrendDirection
|
||||
anomalyScore float64
|
||||
peakScore float64 // highest z-score seen during current cycle
|
||||
volatility float64
|
||||
|
||||
alertedAt time.Time
|
||||
@@ -198,19 +199,19 @@ func (td *TrendDetector) Tick() {
|
||||
defer td.mu.Unlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
// Collect 15s changes from all 4 exchanges
|
||||
// Collect 60s changes from all 4 exchanges
|
||||
var changes []exchangeChange
|
||||
if entry.BG15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG15s})
|
||||
if entry.BG60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG60s})
|
||||
}
|
||||
if entry.HL15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL15s})
|
||||
if entry.HL60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL60s})
|
||||
}
|
||||
if entry.BN15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN15s})
|
||||
if entry.BN60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN60s})
|
||||
}
|
||||
if entry.OKX15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX15s})
|
||||
if entry.OKX60s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX60s})
|
||||
}
|
||||
|
||||
if len(changes) < 3 {
|
||||
@@ -258,8 +259,11 @@ func (td *TrendDetector) Tick() {
|
||||
cs.volatility = cs.volatility*(1-alpha) + maxAbs*alpha
|
||||
}
|
||||
|
||||
// Update anomaly score
|
||||
// Update anomaly score — track the peak during the current cycle
|
||||
cs.anomalyScore = zScore
|
||||
if zScore > cs.peakScore {
|
||||
cs.peakScore = zScore
|
||||
}
|
||||
|
||||
// Determine majority direction
|
||||
majorityDir := TrendUp
|
||||
@@ -279,10 +283,11 @@ func (td *TrendDetector) Tick() {
|
||||
cs.direction = majorityDir
|
||||
cs.alertedAt = now
|
||||
cs.stateSince = now
|
||||
cs.peakScore = zScore
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
@@ -296,7 +301,7 @@ func (td *TrendDetector) Tick() {
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
} else {
|
||||
@@ -308,7 +313,7 @@ func (td *TrendDetector) Tick() {
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
@@ -320,7 +325,7 @@ func (td *TrendDetector) Tick() {
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ func (td *TrendDetector) Tick() {
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
// Also immediately go to idle if below threshold
|
||||
@@ -342,16 +347,16 @@ func (td *TrendDetector) Tick() {
|
||||
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,
|
||||
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stale entries (no update for > 60s)
|
||||
cutoff := time.Now().Add(-60 * time.Second)
|
||||
// Cleanup stale entries that never even reached alert state (> 120s)
|
||||
cutoff := time.Now().Add(-120 * time.Second)
|
||||
for coin, cs := range td.coins {
|
||||
if cs.state == TrendIdle && cs.stateSince.Before(cutoff) {
|
||||
if cs.state == TrendIdle && cs.alertedAt.IsZero() && cs.stateSince.Before(cutoff) {
|
||||
delete(td.coins, coin)
|
||||
}
|
||||
}
|
||||
@@ -370,15 +375,16 @@ func (td *TrendDetector) Snapshot() []TrendEntry {
|
||||
|
||||
var result []TrendEntry
|
||||
for coin, cs := range td.coins {
|
||||
if cs.state == TrendIdle {
|
||||
continue // skip idle coins
|
||||
// Skip coins that never even reached alert state
|
||||
if cs.state == TrendIdle && cs.alertedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := TrendEntry{
|
||||
Coin: coin,
|
||||
State: cs.state,
|
||||
Direction: cs.direction,
|
||||
AnomalyScore: math.Round(cs.anomalyScore*100) / 100,
|
||||
AnomalyScore: math.Round(cs.peakScore*100) / 100,
|
||||
Volatility: math.Round(cs.volatility*10000) / 10000,
|
||||
ExChanges: 0,
|
||||
}
|
||||
@@ -416,12 +422,13 @@ func (td *TrendDetector) Snapshot() []TrendEntry {
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
// Sort: confirmed first, then alert, then exhausting
|
||||
// Sort: confirmed first, then alert, then exhausting, then idle (completed)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
order := map[TrendState]int{
|
||||
TrendConfirmed: 0,
|
||||
TrendAlert: 1,
|
||||
TrendExhausting: 2,
|
||||
TrendIdle: 3,
|
||||
}
|
||||
oi := order[result[i].State]
|
||||
oj := order[result[j].State]
|
||||
@@ -464,6 +471,25 @@ func (td *TrendDetector) IsTrending(coin string) bool {
|
||||
return ok && cs.state == TrendConfirmed
|
||||
}
|
||||
|
||||
// IsAnomalous returns true if the coin is in alert or confirmed trend state.
|
||||
func (td *TrendDetector) IsAnomalous(coin string) bool {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
cs, ok := td.coins[coin]
|
||||
return ok && (cs.state == TrendAlert || cs.state == TrendConfirmed)
|
||||
}
|
||||
|
||||
// State returns the human-readable trend state for a coin, or empty string if unknown.
|
||||
func (td *TrendDetector) State(coin string) string {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
cs, ok := td.coins[coin]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return string(cs.state)
|
||||
}
|
||||
|
||||
// GetTrendingCoins returns all coins currently in confirmed trend.
|
||||
func (td *TrendDetector) GetTrendingCoins() map[string]TrendDirection {
|
||||
td.mu.RLock()
|
||||
|
||||
Reference in New Issue
Block a user