mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
* feat(market): add data staleness detection
## 問題背景
解決 PR #703 Part 2: 數據陳舊性檢測
- 修復 DOGEUSDT 式問題:連續價格不變表示數據源異常
- 防止系統處理僵化/過期的市場數據
## 技術方案
### 數據陳舊性檢測 (market/data.go)
- **函數**: `isStaleData(klines []Kline, symbol string) bool`
- **檢測邏輯**:
- 連續 5 個 3 分鐘週期價格完全不變(15 分鐘無波動)
- 價格波動容忍度:0.01%(避免誤報)
- 成交量檢查:價格凍結 + 成交量為 0 → 確認陳舊
- **處理策略**:
- 數據陳舊確認:跳過該幣種,返回錯誤
- 極低波動市場:記錄警告但允許通過(價格穩定但有成交量)
### 調用時機
- 在 `Get()` 函數中,獲取 3m K線後立即檢測
- 早期返回:避免後續無意義的計算和 API 調用
## 實現細節
- **檢測閾值**: 5 個連續週期
- **容忍度**: 0.01% 價格波動
- **日誌**: 英文國際化版本
- **並發安全**: 函數無狀態,安全
## 影響範圍
- ✅ 修改 market/data.go: 新增 isStaleData() + 調用邏輯
- ✅ 新增 log 包導入
- ✅ 50 行新增代碼
## 測試建議
1. 模擬 DOGEUSDT 場景:連續價格不變 + 成交量為 0
2. 驗證日誌輸出:`stale data confirmed: price freeze + zero volume`
3. 正常市場:極低波動但有成交量,應允許通過並記錄警告
## 相關 Issue/PR
- 拆分自 **PR #703** (Part 2/3)
- 基於最新 upstream/dev (3112250)
- 依賴: 無
- 前置: Part 1 (OI 時間序列) - 已提交 PR #798
- 後續: Part 3 (手續費率傳遞)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
* test(market): add comprehensive unit tests for isStaleData function
- Test normal fluctuating data (expects non-stale)
- Test price freeze with zero volume (expects stale)
- Test price freeze with volume (low volatility market)
- Test insufficient data edge case (<5 klines)
- Test boundary conditions (exactly 5 klines)
- Test tolerance threshold (0.01% price change)
- Test mixed scenario (normal → freeze transition)
- Test empty klines edge case
All 8 test cases passed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
---------
Co-authored-by: ZhouYongyou <128128010+zhouyongyou@users.noreply.github.com>
Co-authored-by: tinkle-community <tinklefund@gmail.com>
Co-authored-by: Shui <88711385+hzb1115@users.noreply.github.com>
503 lines
14 KiB
Go
503 lines
14 KiB
Go
package market
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
// generateTestKlines 生成测试用的 K线数据
|
||
func generateTestKlines(count int) []Kline {
|
||
klines := make([]Kline, count)
|
||
for i := 0; i < count; i++ {
|
||
// 生成模拟的价格数据,有一定的波动
|
||
basePrice := 100.0
|
||
variance := float64(i%10) * 0.5
|
||
open := basePrice + variance
|
||
high := open + 1.0
|
||
low := open - 0.5
|
||
close := open + 0.3
|
||
volume := 1000.0 + float64(i*100)
|
||
|
||
klines[i] = Kline{
|
||
OpenTime: int64(i * 180000), // 3分钟间隔
|
||
Open: open,
|
||
High: high,
|
||
Low: low,
|
||
Close: close,
|
||
Volume: volume,
|
||
CloseTime: int64((i+1)*180000 - 1),
|
||
}
|
||
}
|
||
return klines
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_VolumeCollection 测试 Volume 数据收集
|
||
func TestCalculateIntradaySeries_VolumeCollection(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
klineCount int
|
||
expectedVolLen int
|
||
}{
|
||
{
|
||
name: "正常情况 - 20个K线",
|
||
klineCount: 20,
|
||
expectedVolLen: 10, // 应该收集最近10个
|
||
},
|
||
{
|
||
name: "刚好10个K线",
|
||
klineCount: 10,
|
||
expectedVolLen: 10,
|
||
},
|
||
{
|
||
name: "少于10个K线",
|
||
klineCount: 5,
|
||
expectedVolLen: 5, // 应该返回所有5个
|
||
},
|
||
{
|
||
name: "超过10个K线",
|
||
klineCount: 30,
|
||
expectedVolLen: 10, // 应该只返回最近10个
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
klines := generateTestKlines(tt.klineCount)
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
if data == nil {
|
||
t.Fatal("calculateIntradaySeries returned nil")
|
||
}
|
||
|
||
if len(data.Volume) != tt.expectedVolLen {
|
||
t.Errorf("Volume length = %d, want %d", len(data.Volume), tt.expectedVolLen)
|
||
}
|
||
|
||
// 验证 Volume 数据正确性
|
||
if len(data.Volume) > 0 {
|
||
// 计算期望的起始索引
|
||
start := tt.klineCount - 10
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
|
||
// 验证第一个 Volume 值
|
||
expectedFirstVolume := klines[start].Volume
|
||
if data.Volume[0] != expectedFirstVolume {
|
||
t.Errorf("First volume = %.2f, want %.2f", data.Volume[0], expectedFirstVolume)
|
||
}
|
||
|
||
// 验证最后一个 Volume 值
|
||
expectedLastVolume := klines[tt.klineCount-1].Volume
|
||
lastVolume := data.Volume[len(data.Volume)-1]
|
||
if lastVolume != expectedLastVolume {
|
||
t.Errorf("Last volume = %.2f, want %.2f", lastVolume, expectedLastVolume)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_VolumeValues 测试 Volume 值的正确性
|
||
func TestCalculateIntradaySeries_VolumeValues(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 1000.0, High: 101.0, Low: 99.0, Open: 100.0},
|
||
{Close: 101.0, Volume: 1100.0, High: 102.0, Low: 100.0, Open: 101.0},
|
||
{Close: 102.0, Volume: 1200.0, High: 103.0, Low: 101.0, Open: 102.0},
|
||
{Close: 103.0, Volume: 1300.0, High: 104.0, Low: 102.0, Open: 103.0},
|
||
{Close: 104.0, Volume: 1400.0, High: 105.0, Low: 103.0, Open: 104.0},
|
||
{Close: 105.0, Volume: 1500.0, High: 106.0, Low: 104.0, Open: 105.0},
|
||
{Close: 106.0, Volume: 1600.0, High: 107.0, Low: 105.0, Open: 106.0},
|
||
{Close: 107.0, Volume: 1700.0, High: 108.0, Low: 106.0, Open: 107.0},
|
||
{Close: 108.0, Volume: 1800.0, High: 109.0, Low: 107.0, Open: 108.0},
|
||
{Close: 109.0, Volume: 1900.0, High: 110.0, Low: 108.0, Open: 109.0},
|
||
}
|
||
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
expectedVolumes := []float64{1000.0, 1100.0, 1200.0, 1300.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0}
|
||
|
||
if len(data.Volume) != len(expectedVolumes) {
|
||
t.Fatalf("Volume length = %d, want %d", len(data.Volume), len(expectedVolumes))
|
||
}
|
||
|
||
for i, expected := range expectedVolumes {
|
||
if data.Volume[i] != expected {
|
||
t.Errorf("Volume[%d] = %.2f, want %.2f", i, data.Volume[i], expected)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_ATR14 测试 ATR14 计算
|
||
func TestCalculateIntradaySeries_ATR14(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
klineCount int
|
||
expectZero bool
|
||
expectNonZero bool
|
||
}{
|
||
{
|
||
name: "足够数据 - 20个K线",
|
||
klineCount: 20,
|
||
expectNonZero: true,
|
||
},
|
||
{
|
||
name: "刚好15个K线(ATR14需要至少15个)",
|
||
klineCount: 15,
|
||
expectNonZero: true,
|
||
},
|
||
{
|
||
name: "数据不足 - 14个K线",
|
||
klineCount: 14,
|
||
expectZero: true,
|
||
},
|
||
{
|
||
name: "数据不足 - 10个K线",
|
||
klineCount: 10,
|
||
expectZero: true,
|
||
},
|
||
{
|
||
name: "数据不足 - 5个K线",
|
||
klineCount: 5,
|
||
expectZero: true,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
klines := generateTestKlines(tt.klineCount)
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
if data == nil {
|
||
t.Fatal("calculateIntradaySeries returned nil")
|
||
}
|
||
|
||
if tt.expectZero && data.ATR14 != 0 {
|
||
t.Errorf("ATR14 = %.3f, expected 0 (insufficient data)", data.ATR14)
|
||
}
|
||
|
||
if tt.expectNonZero && data.ATR14 <= 0 {
|
||
t.Errorf("ATR14 = %.3f, expected > 0", data.ATR14)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCalculateATR 测试 ATR 计算函数
|
||
func TestCalculateATR(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
klines []Kline
|
||
period int
|
||
expectZero bool
|
||
}{
|
||
{
|
||
name: "正常计算 - 足够数据",
|
||
klines: []Kline{
|
||
{High: 102.0, Low: 100.0, Close: 101.0},
|
||
{High: 103.0, Low: 101.0, Close: 102.0},
|
||
{High: 104.0, Low: 102.0, Close: 103.0},
|
||
{High: 105.0, Low: 103.0, Close: 104.0},
|
||
{High: 106.0, Low: 104.0, Close: 105.0},
|
||
{High: 107.0, Low: 105.0, Close: 106.0},
|
||
{High: 108.0, Low: 106.0, Close: 107.0},
|
||
{High: 109.0, Low: 107.0, Close: 108.0},
|
||
{High: 110.0, Low: 108.0, Close: 109.0},
|
||
{High: 111.0, Low: 109.0, Close: 110.0},
|
||
{High: 112.0, Low: 110.0, Close: 111.0},
|
||
{High: 113.0, Low: 111.0, Close: 112.0},
|
||
{High: 114.0, Low: 112.0, Close: 113.0},
|
||
{High: 115.0, Low: 113.0, Close: 114.0},
|
||
{High: 116.0, Low: 114.0, Close: 115.0},
|
||
},
|
||
period: 14,
|
||
expectZero: false,
|
||
},
|
||
{
|
||
name: "数据不足 - 等于period",
|
||
klines: []Kline{
|
||
{High: 102.0, Low: 100.0, Close: 101.0},
|
||
{High: 103.0, Low: 101.0, Close: 102.0},
|
||
},
|
||
period: 2,
|
||
expectZero: true,
|
||
},
|
||
{
|
||
name: "数据不足 - 少于period",
|
||
klines: []Kline{
|
||
{High: 102.0, Low: 100.0, Close: 101.0},
|
||
},
|
||
period: 14,
|
||
expectZero: true,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
atr := calculateATR(tt.klines, tt.period)
|
||
|
||
if tt.expectZero {
|
||
if atr != 0 {
|
||
t.Errorf("calculateATR() = %.3f, expected 0 (insufficient data)", atr)
|
||
}
|
||
} else {
|
||
if atr <= 0 {
|
||
t.Errorf("calculateATR() = %.3f, expected > 0", atr)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCalculateATR_TrueRange 测试 ATR 的 True Range 计算正确性
|
||
func TestCalculateATR_TrueRange(t *testing.T) {
|
||
// 创建一个简单的测试用例,手动计算期望的 ATR
|
||
klines := []Kline{
|
||
{High: 50.0, Low: 48.0, Close: 49.0}, // TR = 2.0
|
||
{High: 51.0, Low: 49.0, Close: 50.0}, // TR = max(2.0, 2.0, 1.0) = 2.0
|
||
{High: 52.0, Low: 50.0, Close: 51.0}, // TR = max(2.0, 2.0, 1.0) = 2.0
|
||
{High: 53.0, Low: 51.0, Close: 52.0}, // TR = 2.0
|
||
{High: 54.0, Low: 52.0, Close: 53.0}, // TR = 2.0
|
||
}
|
||
|
||
atr := calculateATR(klines, 3)
|
||
|
||
// 期望的计算:
|
||
// TR[1] = max(51-49, |51-49|, |49-49|) = 2.0
|
||
// TR[2] = max(52-50, |52-50|, |50-50|) = 2.0
|
||
// TR[3] = max(53-51, |53-51|, |51-51|) = 2.0
|
||
// 初始 ATR = (2.0 + 2.0 + 2.0) / 3 = 2.0
|
||
// TR[4] = max(54-52, |54-52|, |52-52|) = 2.0
|
||
// 平滑 ATR = (2.0*2 + 2.0) / 3 = 2.0
|
||
|
||
expectedATR := 2.0
|
||
tolerance := 0.01 // 允许小的浮点误差
|
||
|
||
if math.Abs(atr-expectedATR) > tolerance {
|
||
t.Errorf("calculateATR() = %.3f, want approximately %.3f", atr, expectedATR)
|
||
}
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_ConsistencyWithOtherIndicators 测试 Volume 和其他指标的一致性
|
||
func TestCalculateIntradaySeries_ConsistencyWithOtherIndicators(t *testing.T) {
|
||
klines := generateTestKlines(30)
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
// 所有数组应该存在
|
||
if data.MidPrices == nil {
|
||
t.Error("MidPrices should not be nil")
|
||
}
|
||
if data.Volume == nil {
|
||
t.Error("Volume should not be nil")
|
||
}
|
||
|
||
// MidPrices 和 Volume 应该有相同的长度(都是最近10个)
|
||
if len(data.MidPrices) != len(data.Volume) {
|
||
t.Errorf("MidPrices length (%d) should equal Volume length (%d)",
|
||
len(data.MidPrices), len(data.Volume))
|
||
}
|
||
|
||
// 所有 Volume 值应该大于 0
|
||
for i, vol := range data.Volume {
|
||
if vol <= 0 {
|
||
t.Errorf("Volume[%d] = %.2f, should be > 0", i, vol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_EmptyKlines 测试空 K线数据
|
||
func TestCalculateIntradaySeries_EmptyKlines(t *testing.T) {
|
||
klines := []Kline{}
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
if data == nil {
|
||
t.Fatal("calculateIntradaySeries should not return nil for empty klines")
|
||
}
|
||
|
||
// 所有切片应该为空
|
||
if len(data.MidPrices) != 0 {
|
||
t.Errorf("MidPrices length = %d, want 0", len(data.MidPrices))
|
||
}
|
||
if len(data.Volume) != 0 {
|
||
t.Errorf("Volume length = %d, want 0", len(data.Volume))
|
||
}
|
||
|
||
// ATR14 应该为 0(数据不足)
|
||
if data.ATR14 != 0 {
|
||
t.Errorf("ATR14 = %.3f, want 0", data.ATR14)
|
||
}
|
||
}
|
||
|
||
// TestCalculateIntradaySeries_VolumePrecision 测试 Volume 精度保持
|
||
func TestCalculateIntradaySeries_VolumePrecision(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 1234.5678, High: 101.0, Low: 99.0},
|
||
{Close: 101.0, Volume: 9876.5432, High: 102.0, Low: 100.0},
|
||
{Close: 102.0, Volume: 5555.1111, High: 103.0, Low: 101.0},
|
||
}
|
||
|
||
data := calculateIntradaySeries(klines)
|
||
|
||
expectedVolumes := []float64{1234.5678, 9876.5432, 5555.1111}
|
||
|
||
for i, expected := range expectedVolumes {
|
||
if data.Volume[i] != expected {
|
||
t.Errorf("Volume[%d] = %.4f, want %.4f (precision not preserved)",
|
||
i, data.Volume[i], expected)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_NormalData tests that normal fluctuating data returns false
|
||
func TestIsStaleData_NormalData(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 1000},
|
||
{Close: 100.5, Volume: 1200},
|
||
{Close: 99.8, Volume: 900},
|
||
{Close: 100.2, Volume: 1100},
|
||
{Close: 100.1, Volume: 950},
|
||
}
|
||
|
||
result := isStaleData(klines, "BTCUSDT")
|
||
|
||
if result {
|
||
t.Error("Expected false for normal fluctuating data, got true")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_PriceFreezeWithZeroVolume tests that frozen price + zero volume returns true
|
||
func TestIsStaleData_PriceFreezeWithZeroVolume(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
}
|
||
|
||
result := isStaleData(klines, "DOGEUSDT")
|
||
|
||
if !result {
|
||
t.Error("Expected true for frozen price + zero volume, got false")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_PriceFreezeWithVolume tests that frozen price but normal volume returns false
|
||
func TestIsStaleData_PriceFreezeWithVolume(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 1000},
|
||
{Close: 100.0, Volume: 1200},
|
||
{Close: 100.0, Volume: 900},
|
||
{Close: 100.0, Volume: 1100},
|
||
{Close: 100.0, Volume: 950},
|
||
}
|
||
|
||
result := isStaleData(klines, "STABLECOIN")
|
||
|
||
if result {
|
||
t.Error("Expected false for frozen price but normal volume (low volatility market), got true")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_InsufficientData tests that insufficient data (<5 klines) returns false
|
||
func TestIsStaleData_InsufficientData(t *testing.T) {
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
}
|
||
|
||
result := isStaleData(klines, "BTCUSDT")
|
||
|
||
if result {
|
||
t.Error("Expected false for insufficient data (<5 klines), got true")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_ExactlyFiveKlines tests edge case with exactly 5 klines
|
||
func TestIsStaleData_ExactlyFiveKlines(t *testing.T) {
|
||
// Stale case: exactly 5 frozen klines with zero volume
|
||
staleKlines := []Kline{
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
{Close: 100.0, Volume: 0},
|
||
}
|
||
|
||
result := isStaleData(staleKlines, "TESTUSDT")
|
||
if !result {
|
||
t.Error("Expected true for exactly 5 frozen klines with zero volume, got false")
|
||
}
|
||
|
||
// Normal case: exactly 5 klines with fluctuation
|
||
normalKlines := []Kline{
|
||
{Close: 100.0, Volume: 1000},
|
||
{Close: 100.1, Volume: 1100},
|
||
{Close: 99.9, Volume: 900},
|
||
{Close: 100.0, Volume: 1000},
|
||
{Close: 100.05, Volume: 950},
|
||
}
|
||
|
||
result = isStaleData(normalKlines, "TESTUSDT")
|
||
if result {
|
||
t.Error("Expected false for exactly 5 normal klines, got true")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_WithinTolerance tests price changes within tolerance (0.01%)
|
||
func TestIsStaleData_WithinTolerance(t *testing.T) {
|
||
// Price changes within 0.01% tolerance should be treated as frozen
|
||
basePrice := 10000.0
|
||
tolerance := 0.0001 // 0.01%
|
||
smallChange := basePrice * tolerance * 0.5 // Half of tolerance
|
||
|
||
klines := []Kline{
|
||
{Close: basePrice, Volume: 1000},
|
||
{Close: basePrice + smallChange, Volume: 1000},
|
||
{Close: basePrice - smallChange, Volume: 1000},
|
||
{Close: basePrice, Volume: 1000},
|
||
{Close: basePrice + smallChange, Volume: 1000},
|
||
}
|
||
|
||
result := isStaleData(klines, "BTCUSDT")
|
||
|
||
// Should return false because there's normal volume despite tiny price changes
|
||
if result {
|
||
t.Error("Expected false for price within tolerance but with volume, got true")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_MixedScenario tests realistic scenario with some history before freeze
|
||
func TestIsStaleData_MixedScenario(t *testing.T) {
|
||
// Simulate: normal trading → suddenly freezes
|
||
klines := []Kline{
|
||
{Close: 100.0, Volume: 1000}, // Normal
|
||
{Close: 100.5, Volume: 1200}, // Normal
|
||
{Close: 100.2, Volume: 1100}, // Normal
|
||
{Close: 50.0, Volume: 0}, // Freeze starts
|
||
{Close: 50.0, Volume: 0}, // Frozen
|
||
{Close: 50.0, Volume: 0}, // Frozen
|
||
{Close: 50.0, Volume: 0}, // Frozen
|
||
{Close: 50.0, Volume: 0}, // Frozen (last 5 are all frozen)
|
||
}
|
||
|
||
result := isStaleData(klines, "DOGEUSDT")
|
||
|
||
// Should detect stale data based on last 5 klines
|
||
if !result {
|
||
t.Error("Expected true for frozen last 5 klines with zero volume, got false")
|
||
}
|
||
}
|
||
|
||
// TestIsStaleData_EmptyKlines tests edge case with empty slice
|
||
func TestIsStaleData_EmptyKlines(t *testing.T) {
|
||
klines := []Kline{}
|
||
|
||
result := isStaleData(klines, "BTCUSDT")
|
||
|
||
if result {
|
||
t.Error("Expected false for empty klines, got true")
|
||
}
|
||
}
|