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
+770
View File
@@ -0,0 +1,770 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// Binance klines endpoint (unauthenticated, USDⓈ-M futures)
const binanceFapiBase = "https://fapi.binance.com"
const klineCachePath = "data/kline_cache.json"
const signalsCachePath = "data/trend_signals_cache.json"
// Default thresholds for quiet/active classification.
var (
range1hThreshold = 2.0 // 1h max range < 2% → quiet
)
// FilterState stores trend filter results for one coin.
type FilterState struct {
Coin string `json:"coin"`
Range24h float64 `json:"range_24h"` // avg % range of 24 hourly candles
Range1h float64 `json:"range_1h"` // max % range of 12 five-minute candles
EMA52 float64 `json:"ema_52"` // EMA52 of 1h close prices
CurrentPrice float64 `json:"current_price"` // latest Binance price from live feed
PriceAboveEMA bool `json:"price_above_ema"` // current price > EMA52
Quiet24h bool `json:"quiet_24h"` // 24h range below threshold
Quiet1h bool `json:"quiet_1h"` // 1h range below threshold
FreshAnomaly bool `json:"fresh_anomaly"` // coin in alert/confirmed state
PassesFilter bool `json:"passes_filter"` // all conditions met
LastUpdated int64 `json:"last_updated"` // unix millis
// v2: adaptive scoring fields
VolumeRatio float64 `json:"volume_ratio"` // recent 1h volume / 24h avg volume
EMASlope float64 `json:"ema_slope"` // EMA52 slope over last 3 candles (%)
VolBaseline float64 `json:"vol_baseline"` // per-coin 24h volatility baseline (median %)
SignalScore float64 `json:"signal_score"` // composite signal score 0-100
Change1h float64 `json:"change_1h"` // 1h price change % (from 5m klines)
KlineClose float64 `json:"-"` // last 5m kline close (for real-time drift calc, not serialized)
DriftPct float64 `json:"drift_pct"` // real-time drift % from last kline close
}
// klineData holds parsed fields from one Binance kline.
type klineData struct {
High float64
Low float64
Close float64
Volume float64
}
// TrendSignal records a signal event when trade conditions are met.
type TrendSignal struct {
Timestamp int64 `json:"timestamp"`
Coin string `json:"coin"`
Type string `json:"type"` // "enter" or "exit"
Category string `json:"category"` // "full" (anomaly+score>=70) or "high" (score>=90)
SignalScore float64 `json:"signal_score"`
Price float64 `json:"price"`
EMA52 float64 `json:"ema_52"`
EMASlope float64 `json:"ema_slope"`
VolumeRatio float64 `json:"volume_ratio"`
Range24h float64 `json:"range_24h"`
VolBaseline float64 `json:"vol_baseline"`
PriceAboveEMA bool `json:"price_above_ema"`
State string `json:"state"` // trend detector state at time of signal
}
// TrendFilter fetches Binance klines, computes EMA52/ranges, and filters
// coins that show fresh anomaly signals from TrendDetector.
type TrendFilter struct {
mu sync.RWMutex
states map[string]*FilterState
store *PriceStore
trendDetector *TrendDetector
client *http.Client
refreshTicker *time.Ticker
stopCh chan struct{}
// Signal recording
signals []TrendSignal
signaledCoins map[string]bool // coins currently in "full" enter signal state
highScoreCoins map[string]bool // coins currently in "high" enter signal state
OnNewSignal func(TrendSignal) // callback for SSE broadcast
}
// NewTrendFilter creates a TrendFilter. Call Start() to begin periodic refresh.
func NewTrendFilter(store *PriceStore, td *TrendDetector) *TrendFilter {
p := os.Getenv("HTTPS_PROXY")
if p == "" {
p = os.Getenv("https_proxy")
}
log.Printf("[TrendFilter] HTTPS_PROXY=%s", p)
return &TrendFilter{
client: &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
},
states: make(map[string]*FilterState),
store: store,
trendDetector: td,
stopCh: make(chan struct{}),
signaledCoins: make(map[string]bool),
highScoreCoins: make(map[string]bool),
}
}
// Start begins the background kline fetch loop. The first fetch runs immediately.
func (tf *TrendFilter) Start() {
// Load cached data on startup so we have data immediately
if cached := loadCache(); cached != nil {
tf.mu.Lock()
tf.states = cached
tf.mu.Unlock()
}
// Load signal history
tf.loadSignals()
go func() {
tf.fetchBatch()
tf.refreshTicker = time.NewTicker(5 * time.Minute)
for {
select {
case <-tf.refreshTicker.C:
tf.fetchBatch()
case <-tf.stopCh:
return
}
}
}()
}
// Stop stops the background refresh.
func (tf *TrendFilter) Stop() {
close(tf.stopCh)
if tf.refreshTicker != nil {
tf.refreshTicker.Stop()
}
}
// Tick updates anomaly status from TrendDetector and recalculates PassesFilter.
// Call this every ~1s from the SSE broadcast loop (no HTTP calls).
func (tf *TrendFilter) Tick() {
tf.mu.Lock()
defer tf.mu.Unlock()
for coin, st := range tf.states {
// Update fresh anomaly from trend detector
if tf.trendDetector != nil {
st.FreshAnomaly = tf.trendDetector.IsAnomalous(coin)
}
// Update current price from live feed
if p, ok := tf.store.Get(coin, ExBinance); ok && p > 0 {
// Compute real-time drift from last kline close
if st.KlineClose > 0 && st.CurrentPrice > 0 {
st.DriftPct = math.Round((p-st.KlineClose)/st.KlineClose*10000) / 10000
}
st.CurrentPrice = p
if st.EMA52 > 0 {
st.PriceAboveEMA = p > st.EMA52
}
}
// Re-evaluate overall pass
st.PassesFilter = st.Quiet24h && st.Quiet1h && st.FreshAnomaly && st.PriceAboveEMA
// Recalculate signal score (FreshAnomaly and PriceAboveEMA may have changed)
st.SignalScore = computeSignalScore(st)
}
// Check for signal triggers (must hold lock)
tf.checkSignals()
}
// Snapshot returns filter states, sorted with passing coins first.
func (tf *TrendFilter) Snapshot(limit int) []FilterState {
tf.mu.RLock()
defer tf.mu.RUnlock()
result := make([]FilterState, 0, len(tf.states))
for _, st := range tf.states {
result = append(result, *st)
}
sort.Slice(result, func(i, j int) bool {
// Highest signal score first
if result[i].SignalScore != result[j].SignalScore {
return result[i].SignalScore > result[j].SignalScore
}
return result[i].Coin < result[j].Coin
})
if limit > 0 && limit < len(result) {
result = result[:limit]
}
return result
}
// fetchBatch fetches klines for all tracked coins concurrently.
func (tf *TrendFilter) fetchBatch() {
log.Printf("[TrendFilter] Starting kline fetch (%d coins)...", len(TrackedCoins))
// Collect coins that have a Binance symbol
type coinSymbol struct {
name string
symbol string
}
var targets []coinSymbol
for _, tc := range TrackedCoins {
if tc.BN != "" {
targets = append(targets, coinSymbol{name: tc.Name, symbol: tc.BN})
}
}
if len(targets) == 0 {
return
}
// Semaphore: max 20 concurrent goroutines
sem := make(chan struct{}, 20)
var mu sync.Mutex
type coinResult struct {
name string
klines1h []klineData
klines5m []klineData
err error
}
results := make([]coinResult, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
sem <- struct{}{}
go func(idx int, coin, symbol string) {
defer wg.Done()
defer func() { <-sem }()
k1h, err1 := tf.fetchKlines(symbol, "1h", 500)
if err1 != nil {
mu.Lock()
results[idx] = coinResult{name: coin, err: err1}
mu.Unlock()
return
}
k5m, err2 := tf.fetchKlines(symbol, "5m", 12)
if err2 != nil {
mu.Lock()
results[idx] = coinResult{name: coin, err: err2}
mu.Unlock()
return
}
mu.Lock()
results[idx] = coinResult{name: coin, klines1h: k1h, klines5m: k5m}
mu.Unlock()
}(i, t.name, t.symbol)
}
wg.Wait()
// Process results
now := time.Now().UnixMilli()
newStates := make(map[string]*FilterState, len(results))
var errCount int
for _, r := range results {
if r.err != nil || len(r.klines1h) == 0 {
if r.err != nil && errCount < 3 {
log.Printf("[TrendFilter] Error for %s: %v", r.name, r.err)
errCount++
}
continue
}
fs := tf.computeFilterState(r.name, r.klines1h, r.klines5m, now)
newStates[r.name] = fs
}
// Merge: overwrite computed states, preserve anomaly for coins that errored
tf.mu.Lock()
for coin, fs := range newStates {
tf.states[coin] = fs
if tf.trendDetector != nil {
fs.FreshAnomaly = tf.trendDetector.IsAnomalous(coin)
}
if p, ok := tf.store.Get(coin, ExBinance); ok && p > 0 {
fs.CurrentPrice = p
if fs.EMA52 > 0 {
fs.PriceAboveEMA = p > fs.EMA52
}
}
fs.PassesFilter = fs.Quiet24h && fs.Quiet1h && fs.FreshAnomaly && fs.PriceAboveEMA
// Recalculate signal score with live data
fs.SignalScore = computeSignalScore(fs)
}
tf.mu.Unlock()
if len(newStates) > 0 {
tf.saveCache()
}
log.Printf("[TrendFilter] Fetch complete: %d/%d coins have data", len(newStates), len(results))
}
// fetchKlines calls Binance fapi klines endpoint and parses the response.
func (tf *TrendFilter) fetchKlines(symbol, interval string, limit int) ([]klineData, error) {
url := fmt.Sprintf("%s/fapi/v1/klines?symbol=%s&interval=%s&limit=%d",
binanceFapiBase, strings.ToUpper(symbol), interval, limit)
resp, err := tf.client.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", symbol, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("fetch %s: HTTP %d: %s", symbol, resp.StatusCode, string(body))
}
// Binance returns [[time,open,high,low,close,volume,...], ...]
var raw [][]interface{}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode %s: %w", symbol, err)
}
result := make([]klineData, 0, len(raw))
for _, item := range raw {
if len(item) < 6 {
continue
}
h := parseFloat(item[2])
l := parseFloat(item[3])
c := parseFloat(item[4])
v := parseFloat(item[5])
if h > 0 && l > 0 && c > 0 {
result = append(result, klineData{High: h, Low: l, Close: c, Volume: v})
}
}
return result, nil
}
// parseFloat converts a JSON string field to float64.
func parseFloat(v interface{}) float64 {
s, _ := v.(string)
var f float64
_, _ = fmt.Sscanf(s, "%f", &f)
return f
}
// computeFilterState derives all filter fields from kline data.
// v2: adaptive volatility baseline, volume ratio, EMA slope, composite score.
func (tf *TrendFilter) computeFilterState(coin string, k1h, k5m []klineData, now int64) *FilterState {
fs := &FilterState{
Coin: coin,
LastUpdated: now,
}
n := len(k1h)
// ── Compute per-candle ranges for volatility baseline ──
candleRanges := make([]float64, 0, n)
for _, k := range k1h {
if k.Low > 0 {
candleRanges = append(candleRanges, (k.High-k.Low)/k.Low*100)
}
}
// ── Adaptive volatility baseline ──
// Short-term: median range of last 24 candles
// Long-term: median range of ALL candles
// Quiet24h = short-term < long-term * 1.5
if len(candleRanges) >= 24 {
shortTerm := candleRanges
if len(shortTerm) > 24 {
shortTerm = candleRanges[len(candleRanges)-24:]
}
shortMedian := median(shortTerm)
longMedian := median(candleRanges)
fs.VolBaseline = math.Round(longMedian*100) / 100
fs.Range24h = math.Round(shortMedian*100) / 100
fs.Quiet24h = shortMedian < longMedian*1.5
}
// ── Compute EMA52 from hourly close prices ──
if n >= 52 {
prices := make([]float64, n)
for i, k := range k1h {
prices[i] = k.Close
}
ema := computeEMA(prices, 52)
if len(ema) > 0 {
fs.EMA52 = math.Round(ema[len(ema)-1]*10000) / 10000
}
// EMA slope: (ema[-1] - ema[-4]) / ema[-4] * 100
if len(ema) >= 4 && ema[len(ema)-4] > 0 {
slope := (ema[len(ema)-1] - ema[len(ema)-4]) / ema[len(ema)-4] * 100
fs.EMASlope = math.Round(slope*10000) / 10000
}
}
// ── Compute 1h max range from 5m klines ──
if len(k5m) > 0 {
var maxRange float64
for _, k := range k5m {
if k.Low <= 0 {
continue
}
r := (k.High - k.Low) / k.Low * 100
if r > maxRange {
maxRange = r
}
}
fs.Range1h = math.Round(maxRange*100) / 100
fs.Quiet1h = fs.Range1h < range1hThreshold
}
// ── Volume ratio: last 1h volume / 24h avg volume ──
if n >= 24 {
lastVol := k1h[n-1].Volume
var sumVol float64
for i := n - 24; i < n-1; i++ {
sumVol += k1h[i].Volume
}
avgVol := sumVol / 23 // exclude current candle from avg
if avgVol > 0 {
fs.VolumeRatio = math.Round(lastVol/avgVol*100) / 100
}
}
// ── 1h price change % from 5m klines ──
if len(k5m) >= 2 {
firstClose := k5m[0].Close
lastClose := k5m[len(k5m)-1].Close
if firstClose > 0 {
fs.Change1h = math.Round((lastClose-firstClose)/firstClose*10000) / 10000
}
}
// Store last kline close for real-time drift calculation
if len(k5m) > 0 {
fs.KlineClose = k5m[len(k5m)-1].Close
}
// ── Composite SignalScore (0-100) ──
var score float64
if fs.PriceAboveEMA {
score += 25
}
if fs.Quiet24h {
score += 25
}
if fs.Quiet1h {
score += 20
}
if fs.FreshAnomaly {
score += 15
}
if fs.VolumeRatio > 1.5 {
score += 15
}
// Bonus points
if fs.EMASlope > 0.1 {
score += 10
}
if fs.VolumeRatio > 3.0 {
score += 10
}
// 1h price direction
if fs.Change1h > 0 {
score += 15
} else if fs.Change1h < -0.1 {
score -= 20
} else if fs.Change1h < 0 {
score -= 10
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
fs.SignalScore = score
return fs
}
// median returns the median value of a sorted copy of the slice.
func median(values []float64) float64 {
if len(values) == 0 {
return 0
}
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
mid := len(sorted) / 2
if len(sorted)%2 == 0 {
return (sorted[mid-1] + sorted[mid]) / 2
}
return sorted[mid]
}
// computeSignalScore calculates the composite signal score (0-100) from a FilterState.
// Must be called after FreshAnomaly, PriceAboveEMA, and volume fields are set.
func computeSignalScore(fs *FilterState) float64 {
var score float64
if fs.PriceAboveEMA {
score += 25
}
if fs.Quiet24h {
score += 25
}
if fs.Quiet1h {
score += 20
}
if fs.FreshAnomaly {
score += 15
}
if fs.VolumeRatio > 1.5 {
score += 15
}
// Bonus: strong EMA uptrend
if fs.EMASlope > 0.1 {
score += 10
}
// Bonus: very high volume
if fs.VolumeRatio > 3.0 {
score += 10
}
// 1h price direction: positive change adds, negative change subtracts
if fs.Change1h > 0 {
score += 15
} else if fs.Change1h < -0.1 {
score -= 20 // actively dropping — heavily penalize
} else if fs.Change1h < 0 {
score -= 10 // slightly dropping
}
// Real-time drift: if current price is falling below last kline close, penalize
if fs.DriftPct < -0.1 {
score -= 15
} else if fs.DriftPct < 0 {
score -= 5
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score
}
// ── Signal recording ──
const signalScoreThreshold = 70.0
const highScoreThreshold = 90.0
// checkSignals scans all coins for enter/exit signal conditions.
// Must be called with tf.mu held.
func (tf *TrendFilter) checkSignals() {
now := time.Now().UnixMilli()
for coin, st := range tf.states {
if st.EMA52 <= 0 {
continue // no K-line data yet
}
// ── Full signal: FreshAnomaly + score >= 70 ──
fullSignaled := tf.signaledCoins[coin]
if st.FreshAnomaly && st.SignalScore >= signalScoreThreshold {
if !fullSignaled {
tf.recordSignal(now, coin, st, "enter", "full")
}
} else if fullSignaled {
tf.recordSignal(now, coin, st, "exit", "full")
}
// ── High score signal: score >= 90 (no anomaly required) ──
highSignaled := tf.highScoreCoins[coin]
if st.SignalScore >= highScoreThreshold {
if !highSignaled {
tf.recordSignal(now, coin, st, "enter", "high")
}
} else if highSignaled {
tf.recordSignal(now, coin, st, "exit", "high")
}
}
}
// recordSignal creates, stores, and broadcasts a signal event.
func (tf *TrendFilter) recordSignal(now int64, coin string, st *FilterState, sigType, category string) {
sig := TrendSignal{
Timestamp: now,
Coin: coin,
Type: sigType,
Category: category,
SignalScore: st.SignalScore,
Price: st.CurrentPrice,
EMA52: st.EMA52,
EMASlope: st.EMASlope,
VolumeRatio: st.VolumeRatio,
Range24h: st.Range24h,
VolBaseline: st.VolBaseline,
PriceAboveEMA: st.PriceAboveEMA,
State: tf.trendDetectorState(coin),
}
tf.signals = append(tf.signals, sig)
// Track signaled state per category
if sigType == "enter" {
switch category {
case "full":
tf.signaledCoins[coin] = true
case "high":
tf.highScoreCoins[coin] = true
}
} else {
switch category {
case "full":
delete(tf.signaledCoins, coin)
case "high":
delete(tf.highScoreCoins, coin)
}
}
tf.saveSignals()
if tf.OnNewSignal != nil {
tf.OnNewSignal(sig)
}
log.Printf("[TrendFilter] SIGNAL %s/%s: %s score=%.0f price=%.4f vol=%.2fx slope=%.3f%%",
sigType, category, coin, st.SignalScore, st.CurrentPrice, st.VolumeRatio, st.EMASlope)
}
// trendDetectorState reads the current trend detector state for a coin.
func (tf *TrendFilter) trendDetectorState(coin string) string {
if tf.trendDetector == nil {
return ""
}
return tf.trendDetector.State(coin)
}
// GetSignals returns the most recent N signals.
func (tf *TrendFilter) GetSignals(limit int) []TrendSignal {
tf.mu.RLock()
defer tf.mu.RUnlock()
n := len(tf.signals)
if n == 0 {
return nil
}
start := n - limit
if start < 0 {
start = 0
}
result := make([]TrendSignal, n-start)
copy(result, tf.signals[start:])
// Return in reverse order (newest first)
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result
}
// saveSignals persists signals to disk.
func (tf *TrendFilter) saveSignals() {
if len(tf.signals) == 0 {
return
}
// Keep only last 2000 signals
if len(tf.signals) > 2000 {
tf.signals = tf.signals[len(tf.signals)-2000:]
}
data, err := json.Marshal(tf.signals)
if err != nil {
return
}
os.WriteFile(signalsCachePath, data, 0644)
}
// loadSignals reads signals from disk.
func (tf *TrendFilter) loadSignals() {
raw, err := os.ReadFile(signalsCachePath)
if err != nil {
return
}
var sigs []TrendSignal
if err := json.Unmarshal(raw, &sigs); err != nil {
return
}
tf.signals = sigs
for _, s := range sigs {
if s.Type == "enter" {
switch s.Category {
case "full":
tf.signaledCoins[s.Coin] = true
case "high":
tf.highScoreCoins[s.Coin] = true
}
} else {
switch s.Category {
case "full":
delete(tf.signaledCoins, s.Coin)
case "high":
delete(tf.highScoreCoins, s.Coin)
}
}
}
log.Printf("[TrendFilter] Loaded %d signal records", len(tf.signals))
}
// saveCache writes current filter states to disk (transient fields reset on load).
func (tf *TrendFilter) saveCache() {
tf.mu.RLock()
defer tf.mu.RUnlock()
data, err := json.Marshal(tf.states)
if err != nil {
return
}
os.WriteFile(klineCachePath, data, 0644)
}
// loadCache reads filter states from disk, returning only non-transient fields.
func loadCache() map[string]*FilterState {
raw, err := os.ReadFile(klineCachePath)
if err != nil {
return nil
}
var states map[string]*FilterState
if err := json.Unmarshal(raw, &states); err != nil {
return nil
}
// Reset transient fields — they will be set by Tick()
for _, st := range states {
st.CurrentPrice = 0
st.PriceAboveEMA = false
st.FreshAnomaly = false
st.PassesFilter = false
}
log.Printf("[TrendFilter] Loaded %d coins from cache", len(states))
return states
}
// computeEMA calculates EMA over price data for the given period.
// Uses SMA of first `period` values as seed, then EMA formula.
func computeEMA(prices []float64, period int) []float64 {
if len(prices) < period || period < 2 {
return nil
}
result := make([]float64, len(prices))
// SMA seed
var sum float64
for i := 0; i < period; i++ {
sum += prices[i]
}
result[period-1] = sum / float64(period)
// EMA multiplier
multiplier := 2.0 / float64(period+1)
for i := period; i < len(prices); i++ {
result[i] = (prices[i]-result[i-1])*multiplier + result[i-1]
}
// Fill leading entries with SMA value
for i := 0; i < period-1; i++ {
result[i] = result[period-1]
}
return result
}