Dev backtest (#1134)

This commit is contained in:
Rick
2025-11-28 21:34:27 +08:00
committed by GitHub
parent 64a5734011
commit 7eebb4e218
39 changed files with 9293 additions and 125 deletions

View File

@@ -549,6 +549,55 @@ func parseFloat(v interface{}) (float64, error) {
}
}
// BuildDataFromKlines 根据预加载的K线序列构造市场数据快照用于回测/模拟)。
func BuildDataFromKlines(symbol string, primary []Kline, longer []Kline) (*Data, error) {
if len(primary) == 0 {
return nil, fmt.Errorf("primary series is empty")
}
symbol = Normalize(symbol)
current := primary[len(primary)-1]
currentPrice := current.Close
data := &Data{
Symbol: symbol,
CurrentPrice: currentPrice,
CurrentEMA20: calculateEMA(primary, 20),
CurrentMACD: calculateMACD(primary),
CurrentRSI7: calculateRSI(primary, 7),
PriceChange1h: priceChangeFromSeries(primary, time.Hour),
PriceChange4h: priceChangeFromSeries(primary, 4*time.Hour),
OpenInterest: &OIData{Latest: 0, Average: 0},
FundingRate: 0,
IntradaySeries: calculateIntradaySeries(primary),
LongerTermContext: nil,
}
if len(longer) > 0 {
data.LongerTermContext = calculateLongerTermData(longer)
}
return data, nil
}
func priceChangeFromSeries(series []Kline, duration time.Duration) float64 {
if len(series) == 0 || duration <= 0 {
return 0
}
last := series[len(series)-1]
target := last.CloseTime - duration.Milliseconds()
for i := len(series) - 1; i >= 0; i-- {
if series[i].CloseTime <= target {
price := series[i].Close
if price > 0 {
return ((last.Close - price) / price) * 100
}
break
}
}
return 0
}
// isStaleData detects stale data (consecutive price freeze)
// Fix DOGEUSDT-style issue: consecutive N periods with completely unchanged prices indicate data source anomaly
func isStaleData(klines []Kline, symbol string) bool {

104
market/historical.go Normal file
View File

@@ -0,0 +1,104 @@
package market
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
binanceFuturesKlinesURL = "https://fapi.binance.com/fapi/v1/klines"
binanceMaxKlineLimit = 1500
)
// GetKlinesRange 拉取指定时间范围内的 K 线序列(闭区间),返回按时间升序排列的数据。
func GetKlinesRange(symbol string, timeframe string, start, end time.Time) ([]Kline, error) {
symbol = Normalize(symbol)
normTF, err := NormalizeTimeframe(timeframe)
if err != nil {
return nil, err
}
if !end.After(start) {
return nil, fmt.Errorf("end time must be after start time")
}
startMs := start.UnixMilli()
endMs := end.UnixMilli()
var all []Kline
cursor := startMs
client := &http.Client{Timeout: 15 * time.Second}
for cursor < endMs {
req, err := http.NewRequest("GET", binanceFuturesKlinesURL, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Set("symbol", symbol)
q.Set("interval", normTF)
q.Set("limit", fmt.Sprintf("%d", binanceMaxKlineLimit))
q.Set("startTime", fmt.Sprintf("%d", cursor))
q.Set("endTime", fmt.Sprintf("%d", endMs))
req.URL.RawQuery = q.Encode()
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("binance klines api returned status %d: %s", resp.StatusCode, string(body))
}
var raw [][]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
if len(raw) == 0 {
break
}
batch := make([]Kline, len(raw))
for i, item := range raw {
openTime := int64(item[0].(float64))
open, _ := parseFloat(item[1])
high, _ := parseFloat(item[2])
low, _ := parseFloat(item[3])
close, _ := parseFloat(item[4])
volume, _ := parseFloat(item[5])
closeTime := int64(item[6].(float64))
batch[i] = Kline{
OpenTime: openTime,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume,
CloseTime: closeTime,
}
}
all = append(all, batch...)
last := batch[len(batch)-1]
cursor = last.CloseTime + 1
// 若返回数量少于请求上限,说明已到达末尾,可提前退出。
if len(batch) < binanceMaxKlineLimit {
break
}
}
return all, nil
}

63
market/timeframe.go Normal file
View File

@@ -0,0 +1,63 @@
package market
import (
"fmt"
"slices"
"strings"
"time"
)
// supportedTimeframes 定义支持的时间周期与其对应的分钟数。
var supportedTimeframes = map[string]time.Duration{
"1m": time.Minute,
"3m": 3 * time.Minute,
"5m": 5 * time.Minute,
"15m": 15 * time.Minute,
"30m": 30 * time.Minute,
"1h": time.Hour,
"2h": 2 * time.Hour,
"4h": 4 * time.Hour,
"6h": 6 * time.Hour,
"12h": 12 * time.Hour,
"1d": 24 * time.Hour,
}
// NormalizeTimeframe 规范化传入的时间周期字符串(大小写、不带空格),并校验是否受支持。
func NormalizeTimeframe(tf string) (string, error) {
trimmed := strings.TrimSpace(strings.ToLower(tf))
if trimmed == "" {
return "", fmt.Errorf("timeframe cannot be empty")
}
if _, ok := supportedTimeframes[trimmed]; !ok {
return "", fmt.Errorf("unsupported timeframe '%s'", tf)
}
return trimmed, nil
}
// TFDuration 返回给定周期对应的时间长度。
func TFDuration(tf string) (time.Duration, error) {
norm, err := NormalizeTimeframe(tf)
if err != nil {
return 0, err
}
return supportedTimeframes[norm], nil
}
// MustNormalizeTimeframe 与 NormalizeTimeframe 类似,但在不受支持时 panic。
func MustNormalizeTimeframe(tf string) string {
norm, err := NormalizeTimeframe(tf)
if err != nil {
panic(err)
}
return norm
}
// SupportedTimeframes 返回所有受支持的时间周期(已排序的切片)。
func SupportedTimeframes() []string {
keys := make([]string, 0, len(supportedTimeframes))
for k := range supportedTimeframes {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}