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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-1
@@ -208,9 +208,12 @@ type Dashboard struct {
|
||||
|
||||
// Cumulative tracker (1m/5m multi-exchange consensus)
|
||||
cumulativeTracker *CumulativeTracker
|
||||
|
||||
// Trend filter (K-line based quiet + EMA filter)
|
||||
trendFilter *TrendFilter
|
||||
}
|
||||
|
||||
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker) *Dashboard {
|
||||
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter) *Dashboard {
|
||||
d := &Dashboard{
|
||||
hub: NewSSEHub(),
|
||||
history: newPriceHistory(),
|
||||
@@ -224,6 +227,7 @@ func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr strin
|
||||
momentumTracker: momentumTracker,
|
||||
trendDetector: trendDetector,
|
||||
cumulativeTracker: cumulativeTracker,
|
||||
trendFilter: trendFilter,
|
||||
}
|
||||
|
||||
// Wire trend event persistence to SQLite
|
||||
@@ -235,6 +239,13 @@ func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr strin
|
||||
}
|
||||
}
|
||||
|
||||
// Wire trend filter signal broadcast via SSE
|
||||
if trendFilter != nil {
|
||||
trendFilter.OnNewSignal = func(sig TrendSignal) {
|
||||
d.hub.Broadcast("trend_signal", sig)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire cumulative event persistence to SQLite
|
||||
if cumulativeTracker != nil && database != nil {
|
||||
cumulativeTracker.OnEvent = func(ev CmEvent) {
|
||||
@@ -276,6 +287,7 @@ func (d *Dashboard) Run() {
|
||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
|
||||
mux.HandleFunc("GET /api/trend-history", d.handleTrendHistory)
|
||||
mux.HandleFunc("GET /api/cm-history", d.handleCmHistory)
|
||||
mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals)
|
||||
mux.HandleFunc("GET /events", d.handleSSE)
|
||||
mux.HandleFunc("POST /api/stop", d.handleStop)
|
||||
mux.HandleFunc("POST /api/start", d.handleStart)
|
||||
@@ -597,6 +609,15 @@ func (d *Dashboard) broadcastLoop() {
|
||||
d.hub.Broadcast("cumulative", cmData)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Trend filter (K-line based quiet + EMA)
|
||||
if d.trendFilter != nil {
|
||||
d.trendFilter.Tick()
|
||||
filterData := d.trendFilter.Snapshot(0)
|
||||
if len(filterData) > 0 {
|
||||
d.hub.Broadcast("trend_filter", filterData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,6 +785,17 @@ func (d *Dashboard) handleCmHistory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"events": events})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTrendSignals(w http.ResponseWriter, r *http.Request) {
|
||||
var signals []TrendSignal
|
||||
if d.trendFilter != nil {
|
||||
signals = d.trendFilter.GetSignals(100)
|
||||
}
|
||||
if signals == nil {
|
||||
signals = []TrendSignal{}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"signals": signals})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
|
||||
if d.db == nil {
|
||||
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
||||
|
||||
@@ -150,6 +150,24 @@ func (d *DB) migrate() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cm_events_coin ON cm_events(coin);
|
||||
CREATE INDEX IF NOT EXISTS idx_cm_events_created ON cm_events(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trend_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
coin TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
signal_score REAL,
|
||||
price REAL,
|
||||
ema_52 REAL,
|
||||
ema_slope REAL,
|
||||
volume_ratio REAL,
|
||||
range_24h REAL,
|
||||
vol_baseline REAL,
|
||||
price_above_ema INTEGER,
|
||||
state TEXT,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trend_signals_coin ON trend_signals(coin);
|
||||
CREATE INDEX IF NOT EXISTS idx_trend_signals_created ON trend_signals(created_at);
|
||||
`
|
||||
_, err := d.Exec(schema)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// TrendSignalRecord mirrors the trend_signals table row.
|
||||
type TrendSignalRecord struct {
|
||||
ID int64
|
||||
Coin string
|
||||
Type string // "enter" or "exit"
|
||||
SignalScore *float64
|
||||
Price *float64
|
||||
EMA52 *float64
|
||||
EMASlope *float64
|
||||
VolumeRatio *float64
|
||||
Range24h *float64
|
||||
VolBaseline *float64
|
||||
PriceAboveEMA bool
|
||||
State *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveTrendSignal inserts a new trend signal record.
|
||||
func (d *DB) SaveTrendSignal(s *TrendSignalRecord) (int64, error) {
|
||||
pa := 0
|
||||
if s.PriceAboveEMA {
|
||||
pa = 1
|
||||
}
|
||||
res, err := d.Exec(`INSERT INTO trend_signals
|
||||
(coin, type, signal_score, price, ema_52, ema_slope, volume_ratio,
|
||||
range_24h, vol_baseline, price_above_ema, state, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
s.Coin, s.Type, s.SignalScore, s.Price, s.EMA52, s.EMASlope, s.VolumeRatio,
|
||||
s.Range24h, s.VolBaseline, pa, s.State, s.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetTrendSignals returns the most recent N trend signal records.
|
||||
func (d *DB) GetTrendSignals(limit int) ([]TrendSignalRecord, error) {
|
||||
rows, err := d.Query(`SELECT id, coin, type, signal_score, price, ema_52, ema_slope,
|
||||
volume_ratio, range_24h, vol_baseline, price_above_ema, state, created_at
|
||||
FROM trend_signals ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var signals []TrendSignalRecord
|
||||
for rows.Next() {
|
||||
var s TrendSignalRecord
|
||||
var pa int
|
||||
if err := rows.Scan(&s.ID, &s.Coin, &s.Type, &s.SignalScore, &s.Price,
|
||||
&s.EMA52, &s.EMASlope, &s.VolumeRatio, &s.Range24h, &s.VolBaseline,
|
||||
&pa, &s.State, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PriceAboveEMA = pa == 1
|
||||
signals = append(signals, s)
|
||||
}
|
||||
return signals, rows.Err()
|
||||
}
|
||||
+40
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exchange Monitor Dashboard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-Dr4jUtK1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DsdSIpuQ.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-B9OruCsy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-CDE5zNyv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -242,3 +242,23 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||
.trend-confirmed:hover td { background: rgba(63, 185, 80, 0.15) !important; }
|
||||
.trend-exhausting { background: rgba(139, 148, 158, 0.05); }
|
||||
.trend-exhausting:hover td { background: rgba(139, 148, 158, 0.1) !important; }
|
||||
|
||||
/* Trend Filter Card */
|
||||
.text-orange { color: var(--yellow); }
|
||||
#trend-filter-card { grid-column: 1 / -1; }
|
||||
#trend-filter-table td { font-variant-numeric: tabular-nums; }
|
||||
.filter-pass td { background: rgba(63, 185, 80, 0.06); }
|
||||
.filter-pass:hover td { background: rgba(63, 185, 80, 0.12) !important; }
|
||||
|
||||
/* blue text for categories */
|
||||
.text-blue { color: #58a6ff; }
|
||||
|
||||
/* Trend Signal Card */
|
||||
#trend-signal-card { grid-column: 1 / -1; }
|
||||
#trend-signal-table td { font-variant-numeric: tabular-nums; }
|
||||
#high-score-card { grid-column: 1 / -1; }
|
||||
#high-score-table td { font-variant-numeric: tabular-nums; }
|
||||
.signal-enter td { background: rgba(63, 185, 80, 0.08); }
|
||||
.signal-enter:hover td { background: rgba(63, 185, 80, 0.15) !important; }
|
||||
.signal-exit td { background: rgba(139, 148, 158, 0.05); }
|
||||
.signal-exit:hover td { background: rgba(139, 148, 158, 0.1) !important; }
|
||||
|
||||
+218
-167
@@ -28,9 +28,10 @@ export default function App() {
|
||||
const [trades, setTrades] = useState([])
|
||||
const [momentum, setMomentum] = useState([])
|
||||
const [trendData, setTrendData] = useState([])
|
||||
const [trendHistory, setTrendHistory] = useState([])
|
||||
const [cmData, setCmData] = useState([])
|
||||
const [cmHistory, setCmHistory] = useState([])
|
||||
const [trendFilter, setTrendFilter] = useState([])
|
||||
const [trendSignals, setTrendSignals] = useState([])
|
||||
const priceCacheRef = useRef({})
|
||||
|
||||
// Clock
|
||||
@@ -83,6 +84,13 @@ export default function App() {
|
||||
case 'cumulative':
|
||||
setCmData(msg.data || [])
|
||||
break
|
||||
case 'trend_filter':
|
||||
setTrendFilter(msg.data || [])
|
||||
break
|
||||
case 'trend_signal':
|
||||
// Prepend new signal to list
|
||||
setTrendSignals(prev => [msg.data, ...prev].slice(0, 100))
|
||||
break
|
||||
case 'stats':
|
||||
setStats(msg.data || {})
|
||||
if (msg.data && msg.data.blacklist) {
|
||||
@@ -118,23 +126,6 @@ export default function App() {
|
||||
return () => clearInterval(id)
|
||||
}, [loadTrades])
|
||||
|
||||
// Load trend history
|
||||
const loadTrendHistory = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/trend-history')
|
||||
const data = await resp.json()
|
||||
setTrendHistory(data.events || [])
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendHistory()
|
||||
const id = setInterval(loadTrendHistory, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [loadTrendHistory])
|
||||
|
||||
// Load cumulative history
|
||||
const loadCmHistory = useCallback(async () => {
|
||||
try {
|
||||
@@ -152,6 +143,23 @@ export default function App() {
|
||||
return () => clearInterval(id)
|
||||
}, [loadCmHistory])
|
||||
|
||||
// Load trend signals from API
|
||||
const loadTrendSignals = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/trend-signals')
|
||||
const data = await resp.json()
|
||||
if (data.signals) setTrendSignals(data.signals)
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrendSignals()
|
||||
const id = setInterval(loadTrendSignals, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [loadTrendSignals])
|
||||
|
||||
// Handle prices
|
||||
function handlePrices(data) {
|
||||
if (!data || data.length === 0) return
|
||||
@@ -210,6 +218,21 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<div className="grid">
|
||||
{/* Trend Filter (K-line quiet + EMA52) — 最优先 */}
|
||||
<TrendFilterCard filterData={trendFilter} />
|
||||
|
||||
{/* Full Signal Records (FreshAnomaly + Score >= 70) */}
|
||||
<FullSignalCard signals={trendSignals} />
|
||||
|
||||
{/* High Score Signal Records (Score >= 90) */}
|
||||
<HighScoreCard signals={trendSignals} />
|
||||
|
||||
{/* Cumulative Change (1min consensus) */}
|
||||
<CmCard data={cmData} />
|
||||
|
||||
{/* Cumulative History */}
|
||||
<CmHistoryCard history={cmHistory} />
|
||||
|
||||
{/* Price Table */}
|
||||
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
|
||||
|
||||
@@ -219,18 +242,6 @@ export default function App() {
|
||||
{/* Momentum Scanner */}
|
||||
<MomentumCard momentum={momentum} />
|
||||
|
||||
{/* Trend Detection */}
|
||||
<TrendCard trend={trendData} />
|
||||
|
||||
{/* Trend History */}
|
||||
<TrendHistoryCard history={trendHistory} />
|
||||
|
||||
{/* Cumulative Change (1min consensus) */}
|
||||
<CmCard data={cmData} />
|
||||
|
||||
{/* Cumulative History */}
|
||||
<CmHistoryCard history={cmHistory} />
|
||||
|
||||
{/* ---- 交易相关 ---- */}
|
||||
|
||||
{/* Stats Summary */}
|
||||
@@ -958,87 +969,6 @@ function MomentumCard({ momentum }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Trend Detection Card ============
|
||||
function TrendCard({ trend }) {
|
||||
function stateLabel(state) {
|
||||
switch (state) {
|
||||
case 'alert': return '⚠ 异动'
|
||||
case 'confirmed': return '🚀 趋势'
|
||||
case 'exhausting': return '🔄 衰减'
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
|
||||
function stateClass(state) {
|
||||
switch (state) {
|
||||
case 'alert': return 'trend-alert'
|
||||
case 'confirmed': return 'trend-confirmed'
|
||||
case 'exhausting': return 'trend-exhausting'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function dirIcon(dir) {
|
||||
return dir === 'up' ? '\u2191' : '\u2193'
|
||||
}
|
||||
|
||||
function dirClass(dir) {
|
||||
return dir === 'up' ? 'text-green' : 'text-red'
|
||||
}
|
||||
|
||||
function changeClass(val) {
|
||||
if (val == null || val === 0) return ''
|
||||
return val > 0 ? 'text-green' : 'text-red'
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card-wide" id="trend-card">
|
||||
<h2>📈 趋势检测 (价格异动)</h2>
|
||||
<div className="table-wrap" style={{ maxHeight: 300 }}>
|
||||
<table id="trend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>币种</th>
|
||||
<th>状态</th>
|
||||
<th>方向</th>
|
||||
<th>异动分</th>
|
||||
<th>波动率</th>
|
||||
<th>一致数</th>
|
||||
<th>BG 15s</th>
|
||||
<th>HL 15s</th>
|
||||
<th>BN 15s</th>
|
||||
<th>OKX 15s</th>
|
||||
<th>时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trend.length === 0 ? (
|
||||
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
等待检测数据... (需要至少 3 个交易所数据)
|
||||
</td></tr>
|
||||
) : trend.slice(0, 30).map(entry => (
|
||||
<tr key={entry.coin} className={stateClass(entry.state)}>
|
||||
<td><strong>{entry.coin}</strong></td>
|
||||
<td className="trend-state">{stateLabel(entry.state)}</td>
|
||||
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:18}}>{dirIcon(entry.direction)}</td>
|
||||
<td className="text-right" style={{fontWeight:700}}>{(entry.anomaly_score || 0).toFixed(1)}σ</td>
|
||||
<td className="text-right">{(entry.volatility || 0).toFixed(4)}%</td>
|
||||
<td className="text-right">{entry.ex_changes || 0}/4</td>
|
||||
<td className={'text-right ' + changeClass(entry.bg_change)}>{entry.bg_change != null ? entry.bg_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.hl_change)}>{entry.hl_change != null ? entry.hl_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.bn_change)}>{entry.bn_change != null ? entry.bn_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.okx_change)}>{entry.okx_change != null ? entry.okx_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className="text-dim">{entry.duration || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Trend History Card ============
|
||||
// ============ Cumulative Change Card (1min consensus) ============
|
||||
function CmCard({ data }) {
|
||||
function stateLabel(state) {
|
||||
@@ -1087,11 +1017,16 @@ function CmCard({ data }) {
|
||||
<th>HL 5m</th>
|
||||
<th>BN 5m</th>
|
||||
<th>OKX 5m</th>
|
||||
<th colSpan={4} style={{borderLeft:'2px solid var(--border)'}}>1h 趋势</th>
|
||||
<th>BG 1h</th>
|
||||
<th>HL 1h</th>
|
||||
<th>BN 1h</th>
|
||||
<th>OKX 1h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data || data.length === 0 ? (
|
||||
<tr><td colSpan="14" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
<tr><td colSpan="19" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
等待累积数据... (需要至少 1 分钟数据)
|
||||
</td></tr>
|
||||
) : data.slice(0, 30).map(entry => (
|
||||
@@ -1110,6 +1045,11 @@ function CmCard({ data }) {
|
||||
<td className={'text-right ' + changeClass(entry.hl_5m)}>{entry.hl_5m != null ? entry.hl_5m.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.bn_5m)}>{entry.bn_5m != null ? entry.bn_5m.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.okx_5m)}>{entry.okx_5m != null ? entry.okx_5m.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + dirClass(entry.direction)} style={{fontWeight:600,borderLeft:'2px solid var(--border)'}}>{(entry.avg_1h || 0).toFixed(2)}%</td>
|
||||
<td className={'text-right ' + changeClass(entry.bg_1h)}>{entry.bg_1h != null ? entry.bg_1h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.hl_1h)}>{entry.hl_1h != null ? entry.hl_1h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.bn_1h)}>{entry.bn_1h != null ? entry.bn_1h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(entry.okx_1h)}>{entry.okx_1h != null ? entry.okx_1h.toFixed(2) + '%' : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1201,78 +1141,85 @@ function CmHistoryCard({ history }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TrendHistoryCard({ history }) {
|
||||
function stateLabel(state) {
|
||||
switch (state) {
|
||||
case 'alert': return '⚠ 异动'
|
||||
case 'confirmed': return '🚀 趋势'
|
||||
case 'exhausting': return '🔄 衰减'
|
||||
case 'idle': return '✓ 结束'
|
||||
default: return state
|
||||
function TrendFilterCard({ filterData }) {
|
||||
const passing = filterData.filter(f => f.passes_filter).length
|
||||
const highScore = filterData.filter(f => f.signal_score >= 80).length
|
||||
const midScore = filterData.filter(f => f.signal_score >= 50 && f.signal_score < 80).length
|
||||
const anomalyCount = filterData.filter(f => f.fresh_anomaly).length
|
||||
|
||||
// Build header description
|
||||
let desc = `高分${highScore} 中分${midScore}`
|
||||
if (anomalyCount > 0) {
|
||||
desc += ` | ${anomalyCount}币异动中`
|
||||
if (passing > 0) {
|
||||
desc += ` → ${passing}通过!`
|
||||
}
|
||||
} else {
|
||||
desc += ' | 等待异动信号'
|
||||
}
|
||||
|
||||
function stateClass(state) {
|
||||
switch (state) {
|
||||
case 'alert': return 'text-yellow'
|
||||
case 'confirmed': return 'text-green'
|
||||
case 'exhausting': return 'text-dim'
|
||||
case 'idle': return 'text-dim'
|
||||
default: return ''
|
||||
}
|
||||
function scoreClass(s) {
|
||||
if (s == null) return ''
|
||||
if (s >= 80) return 'text-green'
|
||||
if (s >= 50) return 'text-yellow'
|
||||
return 'text-dim'
|
||||
}
|
||||
|
||||
function dirIcon(dir) {
|
||||
return dir === 'up' ? '\u2191' : '\u2193'
|
||||
function volClass(r) {
|
||||
if (r == null || r <= 1.5) return ''
|
||||
if (r > 3.0) return 'text-red'
|
||||
return 'text-orange'
|
||||
}
|
||||
|
||||
function dirClass(dir) {
|
||||
return dir === 'up' ? 'text-green' : 'text-red'
|
||||
}
|
||||
|
||||
function changeClass(val) {
|
||||
if (val == null || val === 0) return ''
|
||||
return val > 0 ? 'text-green' : 'text-red'
|
||||
function slopeClass(s) {
|
||||
if (s == null || s === 0) return ''
|
||||
return s > 0 ? 'text-green' : 'text-red'
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card-wide" id="trend-history-card">
|
||||
<h2>📋 趋势事件记录</h2>
|
||||
<section className="card card-wide" id="trend-filter-card">
|
||||
<h2>趋势过滤 ({passing}通过 / {filterData.length}) <span className="text-dim" style={{fontSize:12,fontWeight:400}}>{desc}</span></h2>
|
||||
<div className="table-wrap" style={{ maxHeight: 400 }}>
|
||||
<table id="trend-history-table">
|
||||
<table id="trend-filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>币种</th>
|
||||
<th>转换</th>
|
||||
<th>方向</th>
|
||||
<th>异动分</th>
|
||||
<th>波动率</th>
|
||||
<th>一致</th>
|
||||
<th>BG</th>
|
||||
<th>HL</th>
|
||||
<th>BN</th>
|
||||
<th>OKX</th>
|
||||
<th>分数</th>
|
||||
<th>24h范围</th>
|
||||
<th>基线</th>
|
||||
<th>1h范围</th>
|
||||
<th>成交量比</th>
|
||||
<th>1h变化</th>
|
||||
<th>EMA52</th>
|
||||
<th>EMA斜率</th>
|
||||
<th>现价</th>
|
||||
<th>> EMA</th>
|
||||
<th>安静24h</th>
|
||||
<th>安静1h</th>
|
||||
<th>异动</th>
|
||||
<th>更新于</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length === 0 ? (
|
||||
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
暂无趋势事件记录
|
||||
</td></tr>
|
||||
) : history.slice(0, 100).map((ev, i) => (
|
||||
<tr key={(ev.timestamp || ev.id || i) + '-' + i}>
|
||||
<td className="text-dim">{ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : (ev.created_at ? new Date(ev.created_at).toLocaleTimeString('zh-CN', { hour12: false }) : '-')}</td>
|
||||
<td><strong>{ev.coin}</strong></td>
|
||||
<td className={stateClass(ev.new_state)}>{ev.prev_state} → {stateLabel(ev.new_state)}</td>
|
||||
<td className={dirClass(ev.direction)} style={{textAlign:'center',fontSize:16}}>{dirIcon(ev.direction)}</td>
|
||||
<td className="text-right">{(ev.z_score || 0).toFixed(1)}σ</td>
|
||||
<td className="text-right">{(ev.volatility || 0).toFixed(4)}%</td>
|
||||
<td className="text-right">{ev.ex_agree || 0}/{ev.ex_total || 0}</td>
|
||||
<td className={'text-right ' + changeClass(ev.bg_change)}>{ev.bg_change != null ? ev.bg_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(ev.hl_change)}>{ev.hl_change != null ? ev.hl_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(ev.bn_change)}>{ev.bn_change != null ? ev.bn_change.toFixed(3) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + changeClass(ev.okx_change)}>{ev.okx_change != null ? ev.okx_change.toFixed(3) + '%' : '-'}</td>
|
||||
{filterData.length === 0 ? (
|
||||
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>等待K线数据...</td></tr>
|
||||
) : filterData.map(entry => (
|
||||
<tr key={entry.coin} className={entry.passes_filter ? 'filter-pass' : ''}>
|
||||
<td><strong>{entry.coin}</strong></td>
|
||||
<td className={'text-right ' + scoreClass(entry.signal_score)} style={{fontWeight:700}}>{entry.signal_score != null ? entry.signal_score.toFixed(0) : '-'}</td>
|
||||
<td className={'text-right ' + (entry.quiet_24h ? 'text-green' : '')}>{entry.range_24h != null ? entry.range_24h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className="text-right text-dim">{entry.vol_baseline != null ? entry.vol_baseline.toFixed(2) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + (entry.quiet_1h ? 'text-green' : '')}>{entry.range_1h != null ? entry.range_1h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className={'text-right ' + volClass(entry.volume_ratio)}>{entry.volume_ratio != null ? entry.volume_ratio.toFixed(2) + 'x' : '-'}</td>
|
||||
<td className={'text-right ' + (entry.change_1h > 0 ? 'text-green' : entry.change_1h < 0 ? 'text-red' : '')}>{entry.change_1h != null ? (entry.change_1h > 0 ? '+' : '') + entry.change_1h.toFixed(2) + '%' : '-'}</td>
|
||||
<td className="text-right">{entry.ema_52 ? entry.ema_52.toFixed(4) : '-'}</td>
|
||||
<td className={'text-right ' + slopeClass(entry.ema_slope)}>{entry.ema_slope != null ? (entry.ema_slope > 0 ? '+' : '') + entry.ema_slope.toFixed(3) + '%' : '-'}</td>
|
||||
<td className="text-right">{entry.current_price ? entry.current_price.toFixed(4) : '-'}</td>
|
||||
<td className={entry.price_above_ema ? 'text-green' : 'text-red'}>{entry.price_above_ema != null ? (entry.price_above_ema ? '↑' : '↓') : '-'}</td>
|
||||
<td className={entry.quiet_24h ? 'text-green' : 'text-dim'}>{entry.quiet_24h != null ? (entry.quiet_24h ? '✓' : '✗') : '-'}</td>
|
||||
<td className={entry.quiet_1h ? 'text-green' : 'text-dim'}>{entry.quiet_1h != null ? (entry.quiet_1h ? '✓' : '✗') : '-'}</td>
|
||||
<td className={entry.fresh_anomaly ? 'text-orange' : 'text-dim'}>{entry.fresh_anomaly != null ? (entry.fresh_anomaly ? '⚠' : '-') : '-'}</td>
|
||||
<td className="text-dim">{entry.last_updated ? new Date(entry.last_updated).toLocaleTimeString('zh-CN', {hour12:false}) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1281,3 +1228,107 @@ function TrendHistoryCard({ history }) {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Full Signal Records Card (FreshAnomaly + Score >= 70) ============
|
||||
function FullSignalCard({ signals }) {
|
||||
const filtered = signals.filter(s => s.category === 'full')
|
||||
const enterCount = filtered.filter(s => s.type === 'enter').length
|
||||
|
||||
return (
|
||||
<section className="card card-wide" id="trend-signal-card">
|
||||
<h2>完整信号 (异动+分数≥70) {enterCount > 0 && <span className="text-green" style={{fontSize:12,fontWeight:400,marginLeft:8}}>共{enterCount}条</span>}</h2>
|
||||
<div className="table-wrap" style={{ maxHeight: 350 }}>
|
||||
<table id="trend-signal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>币种</th>
|
||||
<th>类型</th>
|
||||
<th>分数</th>
|
||||
<th>价格</th>
|
||||
<th>成交量比</th>
|
||||
<th>EMA斜率</th>
|
||||
<th>趋势状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan="8" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
等待完整信号... (FreshAnomaly + 分数≥70)
|
||||
</td></tr>
|
||||
) : filtered.slice(0, 50).map((s, i) => {
|
||||
const rowClass = s.type === 'enter' ? 'signal-enter' : 'signal-exit'
|
||||
const ts = s.timestamp ? new Date(s.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-'
|
||||
return (
|
||||
<tr key={'full-' + s.timestamp + '-' + s.coin + '-' + i} className={rowClass}>
|
||||
<td className="text-dim">{ts}</td>
|
||||
<td><strong>{s.coin}</strong></td>
|
||||
<td className={s.type === 'enter' ? 'text-green' : 'text-dim'} style={{fontWeight:600}}>
|
||||
{s.type === 'enter' ? '开' : '关'}
|
||||
</td>
|
||||
<td className="text-right" style={{fontWeight:700}}>{s.signal_score != null ? s.signal_score.toFixed(0) : '-'}</td>
|
||||
<td className="text-right">{s.price ? s.price.toFixed(4) : '-'}</td>
|
||||
<td className="text-right">{s.volume_ratio != null ? s.volume_ratio.toFixed(2) + 'x' : '-'}</td>
|
||||
<td className="text-right">{s.ema_slope != null ? (s.ema_slope > 0 ? '+' : '') + s.ema_slope.toFixed(3) + '%' : '-'}</td>
|
||||
<td className="text-dim">{s.state || '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ High Score Signal Records Card (Score >= 90) ============
|
||||
function HighScoreCard({ signals }) {
|
||||
const filtered = signals.filter(s => s.category === 'high')
|
||||
const enterCount = filtered.filter(s => s.type === 'enter').length
|
||||
|
||||
return (
|
||||
<section className="card card-wide" id="high-score-card">
|
||||
<h2>高分信号 (分数≥90) {enterCount > 0 && <span className="text-green" style={{fontSize:12,fontWeight:400,marginLeft:8}}>共{enterCount}条</span>}</h2>
|
||||
<div className="table-wrap" style={{ maxHeight: 350 }}>
|
||||
<table id="high-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>币种</th>
|
||||
<th>类型</th>
|
||||
<th>分数</th>
|
||||
<th>价格</th>
|
||||
<th>成交量比</th>
|
||||
<th>EMA斜率</th>
|
||||
<th>趋势状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan="8" className="text-dim" style={{textAlign:'center',padding:20}}>
|
||||
等待高分信号... (分数≥90)
|
||||
</td></tr>
|
||||
) : filtered.slice(0, 50).map((s, i) => {
|
||||
const rowClass = s.type === 'enter' ? 'signal-enter' : 'signal-exit'
|
||||
const ts = s.timestamp ? new Date(s.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-'
|
||||
return (
|
||||
<tr key={'high-' + s.timestamp + '-' + s.coin + '-' + i} className={rowClass}>
|
||||
<td className="text-dim">{ts}</td>
|
||||
<td><strong>{s.coin}</strong></td>
|
||||
<td className={s.type === 'enter' ? 'text-green' : 'text-dim'} style={{fontWeight:600}}>
|
||||
{s.type === 'enter' ? '开' : '关'}
|
||||
</td>
|
||||
<td className="text-right" style={{fontWeight:700}}>{s.signal_score != null ? s.signal_score.toFixed(0) : '-'}</td>
|
||||
<td className="text-right">{s.price ? s.price.toFixed(4) : '-'}</td>
|
||||
<td className="text-right">{s.volume_ratio != null ? s.volume_ratio.toFixed(2) + 'x' : '-'}</td>
|
||||
<td className="text-right">{s.ema_slope != null ? (s.ema_slope > 0 ? '+' : '') + s.ema_slope.toFixed(3) + '%' : '-'}</td>
|
||||
<td className="text-dim">{s.state || '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ func main() {
|
||||
cumulativeTracker := NewCumulativeTracker()
|
||||
log.Printf("[CM] Cumulative change tracking enabled (1m >= %.1f%%, 3+ exchanges)", cumulativeTracker.surgePct1m)
|
||||
|
||||
// Initialize trend filter (Binance K-line based quiet + EMA52 filter)
|
||||
trendFilter := NewTrendFilter(store, trendDetector)
|
||||
trendFilter.Start()
|
||||
defer trendFilter.Stop()
|
||||
|
||||
// Initialize SQLite database
|
||||
database, err := db.Open("")
|
||||
if err != nil {
|
||||
@@ -90,7 +95,7 @@ func main() {
|
||||
trader.startIPCServer()
|
||||
|
||||
// Initialize dashboard (web server + SSE)
|
||||
dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker)
|
||||
dashboard := NewDashboard(store, trader, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter)
|
||||
go dashboard.Run()
|
||||
|
||||
// Spread window tracker — measures how long spreads stay above threshold
|
||||
|
||||
+12
-3
@@ -18,6 +18,7 @@ var momentumWindows = []momentumWindow{
|
||||
{"t1s", 20, "1s"},
|
||||
{"t5s", 100, "5s"},
|
||||
{"t15s", 300, "15s"},
|
||||
{"t60s", 1200, "60s"},
|
||||
}
|
||||
|
||||
const maxMomentumRecords = 600
|
||||
@@ -35,15 +36,19 @@ type MomentumEntry struct {
|
||||
BG1s float64 `json:"bg_1s"`
|
||||
BG5s float64 `json:"bg_5s"`
|
||||
BG15s float64 `json:"bg_15s"`
|
||||
BG60s float64 `json:"bg_60s"`
|
||||
HL1s float64 `json:"hl_1s"`
|
||||
HL5s float64 `json:"hl_5s"`
|
||||
HL15s float64 `json:"hl_15s"`
|
||||
HL60s float64 `json:"hl_60s"`
|
||||
BN1s float64 `json:"bn_1s"`
|
||||
BN5s float64 `json:"bn_5s"`
|
||||
BN15s float64 `json:"bn_15s"`
|
||||
BN60s float64 `json:"bn_60s"`
|
||||
OKX1s float64 `json:"okx_1s"`
|
||||
OKX5s float64 `json:"okx_5s"`
|
||||
OKX15s float64 `json:"okx_15s"`
|
||||
OKX60s float64 `json:"okx_60s"`
|
||||
Score float64 `json:"score"` // max abs change across all windows
|
||||
Direction string `json:"direction"` // "up", "down", "flat", "mixed"
|
||||
}
|
||||
@@ -105,6 +110,7 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
entry.BG1s = changes[0]
|
||||
entry.BG5s = changes[1]
|
||||
entry.BG15s = changes[2]
|
||||
entry.BG60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
if hasHL {
|
||||
@@ -112,6 +118,7 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
entry.HL1s = changes[0]
|
||||
entry.HL5s = changes[1]
|
||||
entry.HL15s = changes[2]
|
||||
entry.HL60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
if hasBN {
|
||||
@@ -119,6 +126,7 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
entry.BN1s = changes[0]
|
||||
entry.BN5s = changes[1]
|
||||
entry.BN15s = changes[2]
|
||||
entry.BN60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
if hasOK {
|
||||
@@ -126,6 +134,7 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
entry.OKX1s = changes[0]
|
||||
entry.OKX5s = changes[1]
|
||||
entry.OKX15s = changes[2]
|
||||
entry.OKX60s = changes[3]
|
||||
allChanges = append(allChanges, changes[:]...)
|
||||
}
|
||||
|
||||
@@ -178,10 +187,10 @@ func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
|
||||
return result
|
||||
}
|
||||
|
||||
// calcWindows computes change% for all 3 windows: (current - old) / old * 100.
|
||||
// calcWindows computes change% for all windows: (current - old) / old * 100.
|
||||
// Returns 0 for windows that don't have enough data yet.
|
||||
func calcWindows(buf *momentumBuffer) [3]float64 {
|
||||
var result [3]float64
|
||||
func calcWindows(buf *momentumBuffer) [4]float64 {
|
||||
var result [4]float64
|
||||
for i, w := range momentumWindows {
|
||||
if buf.count < w.Ticks+1 {
|
||||
continue
|
||||
|
||||
@@ -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()
|
||||
|
||||
+770
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user