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:
jackyu66git
2026-05-06 22:24:59 +08:00
co-authored by Claude Opus 4.6
parent 73dac50a36
commit 559d7bb870
14 changed files with 1316 additions and 260 deletions
+50 -24
View File
@@ -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()