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
+82
-21
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user