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
+82 -21
View File
@@ -22,6 +22,21 @@ type exChange struct {
change float64
}
// shortExName maps full exchange names to short prefixes for JSON keys.
func shortExName(name string) string {
switch name {
case ExBitget:
return "bg"
case ExHyperLiquid:
return "hl"
case ExBinance:
return "bn"
case ExOKX:
return "okx"
}
return name
}
// CmEvent records a cumulative move state transition, persisted to DB.
type CmEvent struct {
Coin string `json:"coin"`
@@ -50,7 +65,7 @@ type cmSnapshot struct {
}
// CumulativeTracker monitors multi-exchange cumulative price changes.
// Takes 1-second snapshots, computes 1m/5m changes, detects consensus surges.
// Takes 1-second snapshots, computes 1m/5m/1h changes, detects consensus surges.
type CumulativeTracker struct {
mu sync.RWMutex
coins map[string][]cmSnapshot // coin → ring buffer of snapshots
@@ -68,6 +83,7 @@ type CumulativeTracker struct {
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%)
surgePct1h float64 // 1h change% threshold to trigger (default: 2.0%)
// Event history (in-memory ring buffer)
events [maxTrendEvents]CmEvent
@@ -86,10 +102,11 @@ func NewCumulativeTracker() *CumulativeTracker {
counts: make(map[string]int),
states: make(map[string]CmState),
prevState: make(map[string]CmState),
maxSnapshots: 300, // 5min at 1s
maxSnapshots: 3600, // 1h at 1s
minExchanges: 3,
surgePct1m: 0.5, // 0.5% in 1min
surgePct5m: 1.0, // 1.0% in 5min
surgePct1h: 2.0, // 2.0% in 1h
}
}
@@ -99,27 +116,40 @@ 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,
}
now := time.Now().UnixMilli()
// Initialize buffer if needed
// Initialize buffer if needed — pre-fill entire ring buffer with this price
// so 1m/5m/1h windows show 0% immediately instead of waiting for data.
if ct.coins[coin] == nil {
ct.coins[coin] = make([]cmSnapshot, ct.maxSnapshots)
ct.heads[coin] = 0
ct.counts[coin] = 0
ct.counts[coin] = ct.maxSnapshots // mark as full
ct.states[coin] = CmNeutral
ct.prevState[coin] = CmNeutral
startTime := now - int64(ct.maxSnapshots-1)*1000
for i := 0; i < ct.maxSnapshots; i++ {
ct.coins[coin][i] = cmSnapshot{
time: startTime + int64(i)*1000,
prices: prices,
}
}
return
}
// Deduplicate: skip if last snapshot is less than 1 second old
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]++
prevIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
if buf[prevIdx].time > now-1000 {
return
}
buf[head] = cmSnapshot{
time: now,
prices: prices,
}
ct.heads[coin] = (head + 1) % ct.maxSnapshots
}
// GetCurrent returns current cumulative change info for all coins, sorted by score desc.
@@ -146,12 +176,13 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
continue
}
// Find snapshots from ~60s ago and ~300s ago
// Find snapshots from ~60s ago, ~300s ago, and ~3600s ago
now := current.time
oneMinAgo := now - 60000
fiveMinAgo := now - 300000
var snap1m, snap5m *cmSnapshot
var found1m, found5m bool
oneHourAgo := now - 3600000
var snap1m, snap5m, snap1h *cmSnapshot
var found1m, found5m, found1h bool
// Walk backwards from current to find closest snapshots
for i := 0; i < count && i < ct.maxSnapshots; i++ {
@@ -168,14 +199,18 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
snap5m = s
found5m = true
}
if !found1h && s.time <= oneHourAgo {
snap1h = s
found1h = true
}
}
if !found1m {
// Use oldest available as 1m approximation
continue
}
// Compute 1m changes per exchange
var changes1m, changes5m []exChange
// Compute 1m/5m/1h changes per exchange
var changes1m, changes5m, changes1h []exChange
for ex, curP := range current.prices {
if curP <= 0 {
@@ -191,6 +226,12 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
changes5m = append(changes5m, exChange{name: ex, change: chg})
}
}
if found1h && snap1h != nil {
if oldP, ok := snap1h.prices[ex]; ok && oldP > 0 {
chg := (curP - oldP) / oldP * 100
changes1h = append(changes1h, exChange{name: ex, change: chg})
}
}
}
if len(changes1m) < ct.minExchanges {
@@ -198,9 +239,10 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
}
// Compute averages and agreement
var sum1m, sum5m float64
var sum1m, sum5m, sum1h float64
agreeUp1m, agreeDown1m := 0, 0
agreeUp5m, agreeDown5m := 0, 0
agreeUp1h, agreeDown1h := 0, 0
for _, c := range changes1m {
sum1m += c.change
@@ -220,11 +262,24 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
}
}
for _, c := range changes1h {
sum1h += c.change
if c.change > 0.01 {
agreeUp1h++
} else if c.change < -0.01 {
agreeDown1h++
}
}
avg1m := sum1m / float64(len(changes1m))
var avg5m float64
if len(changes5m) >= ct.minExchanges {
avg5m = sum5m / float64(len(changes5m))
}
var avg1h float64
if len(changes1h) >= ct.minExchanges {
avg1h = sum1h / float64(len(changes1h))
}
// Determine direction and agreement
majorityDir := "up"
@@ -242,19 +297,25 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
"coin": coin,
"avg_1m": math.Round(avg1m*10000) / 10000,
"avg_5m": math.Round(avg5m*10000) / 10000,
"avg_1h": math.Round(avg1h*10000) / 10000,
"score": math.Round(score*100) / 100,
"direction": majorityDir,
"ex_agree": majority,
"ex_total": len(changes1m),
}
// Individual exchange changes
// Individual exchange changes (using short names: bg, hl, bn, okx)
for _, c := range changes1m {
entry[c.name+"_1m"] = math.Round(c.change*10000) / 10000
entry[shortExName(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
entry[shortExName(c.name)+"_5m"] = math.Round(c.change*10000) / 10000
}
}
if len(changes1h) >= ct.minExchanges {
for _, c := range changes1h {
entry[shortExName(c.name)+"_1h"] = math.Round(c.change*10000) / 10000
}
}