mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 09:11:03 +08:00
Dev backtest (#1134)
This commit is contained in:
@@ -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
104
market/historical.go
Normal 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
63
market/timeframe.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user