feat: 趋势过滤信号记录系统 + 实时涨跌方向判断

- 新增 TrendFilter 信号记录(enter/exit),按完整信号和高分信号两档分类
- 信号持久化到 data/trend_signals_cache.json,开机自动恢复
- 新增 /api/trend-signals API + SSE trend_signal 实时广播
- 前端新增完整信号卡片和高分信号卡片,移除旧趋势检测卡片
- 评分加入 1h 涨跌方向和实时 drift 惩罚,下跌币不触发信号
- OKX 交易所支持(累积变动、动量、趋势检测)
- 修复 trend_filter.go 编译错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-05-06 22:24:59 +08:00
co-authored by Claude Opus 4.6
parent 73dac50a36
commit 559d7bb870
14 changed files with 1316 additions and 260 deletions
+82 -21
View File
@@ -22,6 +22,21 @@ type exChange struct {
change float64
}
// shortExName maps full exchange names to short prefixes for JSON keys.
func shortExName(name string) string {
switch name {
case ExBitget:
return "bg"
case ExHyperLiquid:
return "hl"
case ExBinance:
return "bn"
case ExOKX:
return "okx"
}
return name
}
// CmEvent records a cumulative move state transition, persisted to DB.
type CmEvent struct {
Coin string `json:"coin"`
@@ -50,7 +65,7 @@ type cmSnapshot struct {
}
// CumulativeTracker monitors multi-exchange cumulative price changes.
// Takes 1-second snapshots, computes 1m/5m changes, detects consensus surges.
// Takes 1-second snapshots, computes 1m/5m/1h changes, detects consensus surges.
type CumulativeTracker struct {
mu sync.RWMutex
coins map[string][]cmSnapshot // coin → ring buffer of snapshots
@@ -68,6 +83,7 @@ type CumulativeTracker struct {
minExchanges int // need at least this many exchanges with data (default: 3)
surgePct1m float64 // 1m change% threshold to trigger (default: 0.5%)
surgePct5m float64 // 5m change% threshold to trigger (default: 1.0%)
surgePct1h float64 // 1h change% threshold to trigger (default: 2.0%)
// Event history (in-memory ring buffer)
events [maxTrendEvents]CmEvent
@@ -86,10 +102,11 @@ func NewCumulativeTracker() *CumulativeTracker {
counts: make(map[string]int),
states: make(map[string]CmState),
prevState: make(map[string]CmState),
maxSnapshots: 300, // 5min at 1s
maxSnapshots: 3600, // 1h at 1s
minExchanges: 3,
surgePct1m: 0.5, // 0.5% in 1min
surgePct5m: 1.0, // 1.0% in 5min
surgePct1h: 2.0, // 2.0% in 1h
}
}
@@ -99,27 +116,40 @@ func (ct *CumulativeTracker) Record(coin string, prices map[string]float64) {
ct.mu.Lock()
defer ct.mu.Unlock()
snap := cmSnapshot{
time: time.Now().UnixMilli(),
prices: prices,
}
now := time.Now().UnixMilli()
// Initialize buffer if needed
// Initialize buffer if needed — pre-fill entire ring buffer with this price
// so 1m/5m/1h windows show 0% immediately instead of waiting for data.
if ct.coins[coin] == nil {
ct.coins[coin] = make([]cmSnapshot, ct.maxSnapshots)
ct.heads[coin] = 0
ct.counts[coin] = 0
ct.counts[coin] = ct.maxSnapshots // mark as full
ct.states[coin] = CmNeutral
ct.prevState[coin] = CmNeutral
startTime := now - int64(ct.maxSnapshots-1)*1000
for i := 0; i < ct.maxSnapshots; i++ {
ct.coins[coin][i] = cmSnapshot{
time: startTime + int64(i)*1000,
prices: prices,
}
}
return
}
// Deduplicate: skip if last snapshot is less than 1 second old
buf := ct.coins[coin]
head := ct.heads[coin]
buf[head] = snap
ct.heads[coin] = (head + 1) % ct.maxSnapshots
if ct.counts[coin] < ct.maxSnapshots {
ct.counts[coin]++
prevIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
if buf[prevIdx].time > now-1000 {
return
}
buf[head] = cmSnapshot{
time: now,
prices: prices,
}
ct.heads[coin] = (head + 1) % ct.maxSnapshots
}
// GetCurrent returns current cumulative change info for all coins, sorted by score desc.
@@ -146,12 +176,13 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
continue
}
// Find snapshots from ~60s ago and ~300s ago
// Find snapshots from ~60s ago, ~300s ago, and ~3600s ago
now := current.time
oneMinAgo := now - 60000
fiveMinAgo := now - 300000
var snap1m, snap5m *cmSnapshot
var found1m, found5m bool
oneHourAgo := now - 3600000
var snap1m, snap5m, snap1h *cmSnapshot
var found1m, found5m, found1h bool
// Walk backwards from current to find closest snapshots
for i := 0; i < count && i < ct.maxSnapshots; i++ {
@@ -168,14 +199,18 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
snap5m = s
found5m = true
}
if !found1h && s.time <= oneHourAgo {
snap1h = s
found1h = true
}
}
if !found1m {
// Use oldest available as 1m approximation
continue
}
// Compute 1m changes per exchange
var changes1m, changes5m []exChange
// Compute 1m/5m/1h changes per exchange
var changes1m, changes5m, changes1h []exChange
for ex, curP := range current.prices {
if curP <= 0 {
@@ -191,6 +226,12 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
changes5m = append(changes5m, exChange{name: ex, change: chg})
}
}
if found1h && snap1h != nil {
if oldP, ok := snap1h.prices[ex]; ok && oldP > 0 {
chg := (curP - oldP) / oldP * 100
changes1h = append(changes1h, exChange{name: ex, change: chg})
}
}
}
if len(changes1m) < ct.minExchanges {
@@ -198,9 +239,10 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
}
// Compute averages and agreement
var sum1m, sum5m float64
var sum1m, sum5m, sum1h float64
agreeUp1m, agreeDown1m := 0, 0
agreeUp5m, agreeDown5m := 0, 0
agreeUp1h, agreeDown1h := 0, 0
for _, c := range changes1m {
sum1m += c.change
@@ -220,11 +262,24 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
}
}
for _, c := range changes1h {
sum1h += c.change
if c.change > 0.01 {
agreeUp1h++
} else if c.change < -0.01 {
agreeDown1h++
}
}
avg1m := sum1m / float64(len(changes1m))
var avg5m float64
if len(changes5m) >= ct.minExchanges {
avg5m = sum5m / float64(len(changes5m))
}
var avg1h float64
if len(changes1h) >= ct.minExchanges {
avg1h = sum1h / float64(len(changes1h))
}
// Determine direction and agreement
majorityDir := "up"
@@ -242,19 +297,25 @@ func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
"coin": coin,
"avg_1m": math.Round(avg1m*10000) / 10000,
"avg_5m": math.Round(avg5m*10000) / 10000,
"avg_1h": math.Round(avg1h*10000) / 10000,
"score": math.Round(score*100) / 100,
"direction": majorityDir,
"ex_agree": majority,
"ex_total": len(changes1m),
}
// Individual exchange changes
// Individual exchange changes (using short names: bg, hl, bn, okx)
for _, c := range changes1m {
entry[c.name+"_1m"] = math.Round(c.change*10000) / 10000
entry[shortExName(c.name)+"_1m"] = math.Round(c.change*10000) / 10000
}
if len(changes5m) >= ct.minExchanges {
for _, c := range changes5m {
entry[c.name+"_5m"] = math.Round(c.change*10000) / 10000
entry[shortExName(c.name)+"_5m"] = math.Round(c.change*10000) / 10000
}
}
if len(changes1h) >= ct.minExchanges {
for _, c := range changes1h {
entry[shortExName(c.name)+"_1h"] = math.Round(c.change*10000) / 10000
}
}
+33 -1
View File
@@ -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})
+18
View File
@@ -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 {
+64
View File
@@ -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()
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+20
View File
@@ -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
View File
@@ -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>&gt; 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>
)
}
+6 -1
View File
@@ -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
View File
@@ -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
+50 -24
View File
@@ -51,7 +51,7 @@ type TrendEntry struct {
Direction TrendDirection `json:"direction"`
AnomalyScore float64 `json:"anomaly_score"` // max z-score across all exchanges
Volatility float64 `json:"volatility"` // current EMA volatility baseline
BGChange float64 `json:"bg_change"` // 15s change %
BGChange float64 `json:"bg_change"` // 60s change %
HLChange float64 `json:"hl_change"`
BNChange float64 `json:"bn_change"`
OKXChange float64 `json:"okx_change"`
@@ -72,6 +72,7 @@ type trendCoinState struct {
state TrendState
direction TrendDirection
anomalyScore float64
peakScore float64 // highest z-score seen during current cycle
volatility float64
alertedAt time.Time
@@ -198,19 +199,19 @@ func (td *TrendDetector) Tick() {
defer td.mu.Unlock()
for _, entry := range entries {
// Collect 15s changes from all 4 exchanges
// Collect 60s changes from all 4 exchanges
var changes []exchangeChange
if entry.BG15s != 0 {
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG15s})
if entry.BG60s != 0 {
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG60s})
}
if entry.HL15s != 0 {
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL15s})
if entry.HL60s != 0 {
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL60s})
}
if entry.BN15s != 0 {
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN15s})
if entry.BN60s != 0 {
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN60s})
}
if entry.OKX15s != 0 {
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX15s})
if entry.OKX60s != 0 {
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX60s})
}
if len(changes) < 3 {
@@ -258,8 +259,11 @@ func (td *TrendDetector) Tick() {
cs.volatility = cs.volatility*(1-alpha) + maxAbs*alpha
}
// Update anomaly score
// Update anomaly score — track the peak during the current cycle
cs.anomalyScore = zScore
if zScore > cs.peakScore {
cs.peakScore = zScore
}
// Determine majority direction
majorityDir := TrendUp
@@ -279,10 +283,11 @@ func (td *TrendDetector) Tick() {
cs.direction = majorityDir
cs.alertedAt = now
cs.stateSince = now
cs.peakScore = zScore
cs.confirmCount = 1
cs.misalignCount = 0
td.recordEvent(entry.Coin, "idle", "alert", string(majorityDir),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
@@ -296,7 +301,7 @@ func (td *TrendDetector) Tick() {
cs.confirmedAt = now
cs.stateSince = now
td.recordEvent(entry.Coin, "alert", "confirmed", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
} else {
@@ -308,7 +313,7 @@ func (td *TrendDetector) Tick() {
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "alert", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
}
@@ -320,7 +325,7 @@ func (td *TrendDetector) Tick() {
cs.state = TrendExhausting
cs.stateSince = now
td.recordEvent(entry.Coin, "confirmed", "exhausting", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
@@ -332,7 +337,7 @@ func (td *TrendDetector) Tick() {
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
// Also immediately go to idle if below threshold
@@ -342,16 +347,16 @@ func (td *TrendDetector) Tick() {
cs.confirmCount = 0
cs.misalignCount = 0
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
zScore, cs.volatility, entry.BG60s, entry.HL60s, entry.BN60s, entry.OKX60s,
majorityCount, len(changes))
}
}
}
// Cleanup stale entries (no update for > 60s)
cutoff := time.Now().Add(-60 * time.Second)
// Cleanup stale entries that never even reached alert state (> 120s)
cutoff := time.Now().Add(-120 * time.Second)
for coin, cs := range td.coins {
if cs.state == TrendIdle && cs.stateSince.Before(cutoff) {
if cs.state == TrendIdle && cs.alertedAt.IsZero() && cs.stateSince.Before(cutoff) {
delete(td.coins, coin)
}
}
@@ -370,15 +375,16 @@ func (td *TrendDetector) Snapshot() []TrendEntry {
var result []TrendEntry
for coin, cs := range td.coins {
if cs.state == TrendIdle {
continue // skip idle coins
// Skip coins that never even reached alert state
if cs.state == TrendIdle && cs.alertedAt.IsZero() {
continue
}
entry := TrendEntry{
Coin: coin,
State: cs.state,
Direction: cs.direction,
AnomalyScore: math.Round(cs.anomalyScore*100) / 100,
AnomalyScore: math.Round(cs.peakScore*100) / 100,
Volatility: math.Round(cs.volatility*10000) / 10000,
ExChanges: 0,
}
@@ -416,12 +422,13 @@ func (td *TrendDetector) Snapshot() []TrendEntry {
result = append(result, entry)
}
// Sort: confirmed first, then alert, then exhausting
// Sort: confirmed first, then alert, then exhausting, then idle (completed)
sort.Slice(result, func(i, j int) bool {
order := map[TrendState]int{
TrendConfirmed: 0,
TrendAlert: 1,
TrendExhausting: 2,
TrendIdle: 3,
}
oi := order[result[i].State]
oj := order[result[j].State]
@@ -464,6 +471,25 @@ func (td *TrendDetector) IsTrending(coin string) bool {
return ok && cs.state == TrendConfirmed
}
// IsAnomalous returns true if the coin is in alert or confirmed trend state.
func (td *TrendDetector) IsAnomalous(coin string) bool {
td.mu.RLock()
defer td.mu.RUnlock()
cs, ok := td.coins[coin]
return ok && (cs.state == TrendAlert || cs.state == TrendConfirmed)
}
// State returns the human-readable trend state for a coin, or empty string if unknown.
func (td *TrendDetector) State(coin string) string {
td.mu.RLock()
defer td.mu.RUnlock()
cs, ok := td.coins[coin]
if !ok {
return ""
}
return string(cs.state)
}
// GetTrendingCoins returns all coins currently in confirmed trend.
func (td *TrendDetector) GetTrendingCoins() map[string]TrendDirection {
td.mu.RLock()
+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
}