605 lines
17 KiB
Go
605 lines
17 KiB
Go
package okx
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"fmt"
|
||
cfg "gitea.zjmud.xyz/phyer/tanya/config"
|
||
"gitea.zjmud.xyz/phyer/tanya/elasticilm"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// formatTimestamp 将毫秒时间戳字符串转换为可读格式
|
||
func formatTimestamp(ts string) (string, error) {
|
||
millis, err := strconv.ParseInt(ts, 10, 64)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
return time.UnixMilli(millis).In(loc).Format("2006-01-02T15:04:05+08:00"), nil
|
||
}
|
||
|
||
// CandleList 封装Candle数组并提供序列化方法
|
||
type CandleList struct {
|
||
Candles []*Candle
|
||
CoinPair string // 交易对名称,如 BTC-USDT
|
||
Period string // 周期名称,如 15M, 1D
|
||
}
|
||
|
||
func MakeCandleList(instId string, period string, startTime time.Time, endTime time.Time, blockSize int) (*CandleList, error) {
|
||
cl := &CandleList{
|
||
CoinPair: instId,
|
||
Period: period,
|
||
}
|
||
service := NewOkxPublicDataService()
|
||
var allCandles []*Candle
|
||
currentTime := endTime
|
||
|
||
for currentTime.After(startTime) || currentTime.Equal(startTime) {
|
||
req := CandlesRequest{
|
||
InstID: instId,
|
||
Bar: period,
|
||
After: strconv.FormatInt(currentTime.UnixMilli(), 10),
|
||
Limit: strconv.Itoa(blockSize),
|
||
}
|
||
candles, err := service.GetCandles(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(candles) == 0 {
|
||
break
|
||
}
|
||
allCandles = append(allCandles, candles...)
|
||
// 更新currentTime为最后一条数据的时间戳
|
||
lastCandleTime, _ := strconv.ParseInt(candles[len(candles)-1].Timestamp, 10, 64)
|
||
newCurrentTime := time.UnixMilli(lastCandleTime).Add(time.Millisecond)
|
||
currentTime = newCurrentTime
|
||
}
|
||
fmt.Println("lens of allCandles: ", len(allCandles))
|
||
cl.Candles = allCandles
|
||
return cl, nil
|
||
}
|
||
|
||
// ToJson 将Candle数组序列化为JSON字符串
|
||
func (cl *CandleList) ToJson() (string, error) {
|
||
// 创建临时结构体用于格式化时间戳
|
||
type FormattedCandle struct {
|
||
Timestamp string `json:"timestamp"`
|
||
Open string `json:"open"`
|
||
High string `json:"high"`
|
||
Low string `json:"low"`
|
||
Close string `json:"close"`
|
||
Volume string `json:"volume"`
|
||
VolumeCcy string `json:"volumeCcy"`
|
||
}
|
||
|
||
var formattedCandles []FormattedCandle
|
||
for _, candle := range cl.Candles {
|
||
formattedTs, err := formatTimestamp(candle.Timestamp)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
formattedCandles = append(formattedCandles, FormattedCandle{
|
||
Timestamp: formattedTs,
|
||
Open: candle.Open,
|
||
High: candle.High,
|
||
Low: candle.Low,
|
||
Close: candle.Close,
|
||
Volume: candle.Volume,
|
||
VolumeCcy: candle.VolumeCcy,
|
||
})
|
||
}
|
||
|
||
jsonData, err := json.Marshal(formattedCandles)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(jsonData), nil
|
||
}
|
||
|
||
// ToCsv 将Candle数组序列化为CSV字符串
|
||
func (cl *CandleList) ToCsv() (string, error) {
|
||
var sb strings.Builder
|
||
writer := csv.NewWriter(&sb)
|
||
|
||
// 写入CSV头
|
||
header := []string{"Timestamp", "Open", "High", "Low", "Close", "Volume", "VolumeCcy"}
|
||
if err := writer.Write(header); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// 调试信息
|
||
fmt.Printf("Number of candles: %d\n", len(cl.Candles))
|
||
|
||
for _, candle := range cl.Candles {
|
||
formattedTs, err := formatTimestamp(candle.Timestamp)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
record := []string{
|
||
formattedTs,
|
||
candle.Open,
|
||
candle.High,
|
||
candle.Low,
|
||
candle.Close,
|
||
candle.Volume,
|
||
candle.VolumeCcy,
|
||
}
|
||
if err := writer.Write(record); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
|
||
writer.Flush()
|
||
if err := writer.Error(); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return sb.String(), nil
|
||
}
|
||
|
||
// ToFluentd 将Candle数据发送到Fluentd
|
||
func (cl *CandleList) ToFluentd() error {
|
||
// 获取当前年份
|
||
currentYear := time.Now().Year()
|
||
|
||
// 构造tag,格式为:tanya.candle.交易对.年份.周期(保持周期原样)
|
||
tag := fmt.Sprintf("tanya.candle.%s.%d.%s", cl.CoinPair, currentYear, cl.Period)
|
||
|
||
// 解析周期
|
||
duration, err := parsePeriod(cl.Period)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid period: %v", err)
|
||
}
|
||
|
||
// 逐条发送记录
|
||
for _, candle := range cl.Candles {
|
||
// 验证时间戳是否为周期的整数倍
|
||
ts, err := strconv.ParseInt(candle.Timestamp, 10, 64)
|
||
if err != nil {
|
||
fmt.Println("invalid timestamp:", ts, err)
|
||
continue
|
||
}
|
||
|
||
// 转换为东八区时间
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
cstTime := time.UnixMilli(ts).In(loc)
|
||
|
||
// 对于日线数据,检查是否为当天的 00:00:00
|
||
if cl.Period == "1D" {
|
||
if cstTime.Hour() != 0 || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period :%s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
} else {
|
||
// 对于其他周期,使用原来的对齐检查
|
||
if cstTime.UnixMilli()%duration.Milliseconds() != 0 {
|
||
return fmt.Errorf("timestamp %d is not aligned with period %s", ts, cl.Period)
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
formattedTime := cstTime.Format("2006-01-02 15:04:05")
|
||
|
||
// 构造记录
|
||
record := map[string]interface{}{
|
||
"_id": ts, // 使用时间戳作为_id
|
||
"dataTime": formattedTime,
|
||
"open": candle.Open,
|
||
"high": candle.High,
|
||
"low": candle.Low,
|
||
"close": candle.Close,
|
||
"volume": candle.Volume,
|
||
"volumeCcy": candle.VolumeCcy,
|
||
}
|
||
|
||
// 构造请求体
|
||
payload := map[string]interface{}{
|
||
"tag": tag,
|
||
"record": record,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal data: %v", err)
|
||
}
|
||
|
||
// 尝试从不同层级加载配置
|
||
var config *cfg.Config
|
||
configPaths := []string{
|
||
"config/config.json", // 当前目录下的config目录
|
||
"../config/config.json", // 上一级目录下的config目录
|
||
"../../config/config.json", // 上两级目录下的config目录
|
||
}
|
||
|
||
for _, path := range configPaths {
|
||
config, err = cfg.LoadConfig(path)
|
||
if err == nil {
|
||
break
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load config after trying paths: %v. Tried paths: %v", err, configPaths)
|
||
}
|
||
|
||
// 构造完整URL,添加json参数
|
||
fullURL := fmt.Sprintf("%s/%s?json", strings.TrimRight(config.Fluentd.URL, "/"), tag)
|
||
|
||
// 输出完整请求URL和请求体到日志
|
||
fmt.Printf("Sending request to URL: %s\n", fullURL)
|
||
fmt.Printf("Request Body: %s\n", string(jsonData))
|
||
|
||
// 创建带超时的HTTP客户端
|
||
client := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
|
||
// 创建请求
|
||
req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create request: %v", err)
|
||
}
|
||
|
||
// 设置请求头
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "application/json")
|
||
|
||
// 发送HTTP请求
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to send data to Fluentd: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取响应体
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read response body: %v", err)
|
||
}
|
||
|
||
// 输出完整的响应信息
|
||
fmt.Printf("HTTP Response Status: %s\n", resp.Status)
|
||
fmt.Printf("HTTP Response Headers: %v\n", resp.Header)
|
||
fmt.Printf("HTTP Response Body: %s\n", string(body))
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
fmt.Printf("Successfully sent record to Fluentd: %s\n", fullURL)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// CalculateCrossPair 计算两个币对的中间币对
|
||
func (cl *CandleList) CalculateCrossPair(other *CandleList) (*CandleList, error) {
|
||
// 验证周期是否一致
|
||
if cl.Period != other.Period {
|
||
return nil, fmt.Errorf("period mismatch: %s vs %s", cl.Period, other.Period)
|
||
}
|
||
|
||
// 解析币对名称
|
||
base1, quote1 := parsePair(cl.CoinPair)
|
||
base2, quote2 := parsePair(other.CoinPair)
|
||
|
||
// 验证是否构成交叉币对
|
||
if quote1 != quote2 {
|
||
return nil, fmt.Errorf("not a valid cross pair: %s and %s", cl.CoinPair, other.CoinPair)
|
||
}
|
||
|
||
// 创建新的CandleList
|
||
crossPair := fmt.Sprintf("%s-%s", base1, base2)
|
||
result := &CandleList{
|
||
CoinPair: crossPair,
|
||
Period: cl.Period,
|
||
}
|
||
|
||
fmt.Println("result.CoinPair, result.Period: ", result.CoinPair, result.Period)
|
||
// 创建时间戳映射
|
||
clMap := make(map[string]*Candle)
|
||
for _, candle := range cl.Candles {
|
||
clMap[candle.Timestamp] = candle
|
||
}
|
||
|
||
// 遍历另一个CandleList,寻找匹配的时间戳
|
||
for _, otherCandle := range other.Candles {
|
||
if clCandle, exists := clMap[otherCandle.Timestamp]; exists {
|
||
// 计算中间币对的价格
|
||
open, err := calculateCrossPrice(clCandle.Open, otherCandle.Open)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
high, err := calculateCrossPrice(clCandle.High, otherCandle.High)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
low, err := calculateCrossPrice(clCandle.Low, otherCandle.Low)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
close, err := calculateCrossPrice(clCandle.Close, otherCandle.Close)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 创建新的Candle
|
||
crossCandle := &Candle{
|
||
Timestamp: otherCandle.Timestamp,
|
||
Open: open,
|
||
High: high,
|
||
Low: low,
|
||
Close: close,
|
||
Volume: "0", // 成交量不直接转换
|
||
VolumeCcy: "0", // 成交量不直接转换
|
||
}
|
||
|
||
result.Candles = append(result.Candles, crossCandle)
|
||
}
|
||
}
|
||
|
||
if len(result.Candles) == 0 {
|
||
return nil, fmt.Errorf("no overlapping timestamps found")
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// parsePair 解析币对名称
|
||
func parsePair(pair string) (string, string) {
|
||
parts := strings.Split(pair, "-")
|
||
if len(parts) != 2 {
|
||
return "", ""
|
||
}
|
||
return parts[0], parts[1]
|
||
}
|
||
|
||
// calculateCrossPrice 计算交叉币对价格
|
||
func calculateCrossPrice(price1, price2 string) (string, error) {
|
||
p1, err := strconv.ParseFloat(price1, 64)
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid price1: %v", err)
|
||
}
|
||
p2, err := strconv.ParseFloat(price2, 64)
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid price2: %v", err)
|
||
}
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid price2: %v", err)
|
||
}
|
||
|
||
if p2 == 0 {
|
||
return "", fmt.Errorf("division by zero")
|
||
}
|
||
|
||
result := p1 / p2
|
||
return fmt.Sprintf("%.8f", result), nil
|
||
}
|
||
|
||
// ToElastic 将 Candle 数据发送到 Elasticsearch
|
||
func (cl *CandleList) ToElastic() error {
|
||
client := &http.Client{Timeout: 30 * time.Second}
|
||
|
||
_, err := parsePeriod(cl.Period)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse period: %v", err)
|
||
}
|
||
|
||
var config *cfg.Config
|
||
configPaths := []string{"config/config.json", "../config/config.json", "../../config/config.json"}
|
||
for _, path := range configPaths {
|
||
config, err = cfg.LoadConfig(path)
|
||
if err == nil {
|
||
break
|
||
}
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load config: %v", err)
|
||
}
|
||
|
||
if len(cl.Candles) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// 按月分组数据
|
||
type monthlyCandles struct {
|
||
Index string
|
||
Candles []*Candle
|
||
IndexTime time.Time
|
||
}
|
||
indexMap := make(map[string]monthlyCandles)
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
|
||
for _, candle := range cl.Candles {
|
||
ts, err := strconv.ParseInt(candle.Timestamp, 10, 64)
|
||
if err != nil {
|
||
fmt.Printf("invalid timestamp: %s, skipping: %v\n", candle.Timestamp, err)
|
||
continue
|
||
}
|
||
cstTime := time.UnixMilli(ts).In(loc)
|
||
index := fmt.Sprintf("logstash-%s.candle.%s.%d-%02d",
|
||
strings.ToLower(cl.Period),
|
||
strings.ToLower(cl.CoinPair),
|
||
cstTime.Year(),
|
||
int(cstTime.Month()),
|
||
)
|
||
|
||
if group, exists := indexMap[index]; exists {
|
||
group.Candles = append(group.Candles, candle)
|
||
indexMap[index] = group
|
||
} else {
|
||
indexMap[index] = monthlyCandles{
|
||
Index: index,
|
||
Candles: []*Candle{candle},
|
||
IndexTime: cstTime,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 为每个月的索引配置 ILM 并插入数据
|
||
for _, group := range indexMap {
|
||
// 配置 ILM,使用该月第一条数据的时间
|
||
err = elasticilm.ConfigureILM(client, config, "candle", strings.ToLower(cl.Period), group.IndexTime)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to configure ILM for index %s: %v", group.Index, err)
|
||
}
|
||
|
||
// 插入该月的所有数据
|
||
for _, candle := range group.Candles {
|
||
ts, err := strconv.ParseInt(candle.Timestamp, 10, 64)
|
||
if err != nil {
|
||
fmt.Printf("invalid timestamp: %s, skipping: %v\n", candle.Timestamp, err)
|
||
continue
|
||
}
|
||
cstTime := time.UnixMilli(ts).In(loc)
|
||
|
||
// 时间戳对齐检查
|
||
duration, err := parsePeriod(cl.Period)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse period: %v", err)
|
||
}
|
||
|
||
// 对于日线及以上周期,检查是否为周期的整数倍
|
||
if duration >= 24*time.Hour {
|
||
// 特殊处理1D周期
|
||
if cl.Period == "1D" {
|
||
// 检查是否为当天的00:00:00
|
||
if cstTime.Hour() != 0 || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
} else {
|
||
// 对于其他日线及以上周期,使用原来的检查逻辑
|
||
totalHours := cstTime.Unix() / 3600
|
||
periodHours := int64(duration.Hours())
|
||
|
||
if totalHours%periodHours != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
|
||
if cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) has non-zero minutes/seconds for period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
}
|
||
} else if duration >= time.Hour {
|
||
// 对于小时级周期,检查小时、分钟、秒是否对齐
|
||
switch cl.Period {
|
||
case "1H":
|
||
if cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) has non-zero minutes/seconds for period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
case "2H":
|
||
if cstTime.Hour()%2 != 0 || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
case "4H":
|
||
if cstTime.Hour()%4 != 0 || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
case "6H":
|
||
if (cstTime.Hour()%6 != 0) || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
case "12H":
|
||
if (cstTime.Hour() != 0 && cstTime.Hour() != 12) || cstTime.Minute() != 0 || cstTime.Second() != 0 {
|
||
return fmt.Errorf("timestamp %d (%s) is not aligned with period %s", ts, cstTime.Format("2006-01-02 15:04:05"), cl.Period)
|
||
}
|
||
default:
|
||
if cstTime.UnixMilli()%duration.Milliseconds() != 0 {
|
||
return fmt.Errorf("timestamp %d is not aligned with period %s", ts, cl.Period)
|
||
}
|
||
}
|
||
} else {
|
||
// 对于分钟级周期,使用毫秒取模检查
|
||
if cstTime.UnixMilli()%duration.Milliseconds() != 0 {
|
||
return fmt.Errorf("timestamp %d is not aligned with period %s", ts, cl.Period)
|
||
}
|
||
}
|
||
|
||
record := map[string]interface{}{
|
||
"dataTime": cstTime.Format("2006-01-02 15:04:05"),
|
||
"open": candle.Open,
|
||
"high": candle.High,
|
||
"low": candle.Low,
|
||
"close": candle.Close,
|
||
"volume": candle.Volume,
|
||
"volumeCcy": candle.VolumeCcy,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(record)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal data: %v", err)
|
||
}
|
||
|
||
fullURL := fmt.Sprintf("%s/%s/_doc/%d", strings.TrimRight(config.Elasticsearch.URL, "/"), group.Index, ts)
|
||
req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create request: %v", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.SetBasicAuth(config.Elasticsearch.Auth.Username, config.Elasticsearch.Auth.Password)
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to send data to Elasticsearch: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
fmt.Printf("Successfully sent record to Elasticsearch: %s\n", fullURL)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// parsePeriod 将时间框架字符串转换为 time.Duration
|
||
func parsePeriod(period string) (time.Duration, error) {
|
||
switch period {
|
||
case "1m":
|
||
return time.Minute, nil
|
||
case "3m":
|
||
return 3 * time.Minute, nil
|
||
case "5m":
|
||
return 5 * time.Minute, nil
|
||
case "15m":
|
||
return 15 * time.Minute, nil
|
||
case "30m":
|
||
return 30 * time.Minute, nil
|
||
case "1H":
|
||
return time.Hour, nil
|
||
case "2H":
|
||
return 2 * time.Hour, nil
|
||
case "4H":
|
||
return 4 * time.Hour, nil
|
||
case "6H":
|
||
return 6 * time.Hour, nil
|
||
case "12H":
|
||
return 12 * time.Hour, nil
|
||
case "1D", "1d":
|
||
return 24 * time.Hour, nil
|
||
case "2D":
|
||
return 48 * time.Hour, nil
|
||
case "5D":
|
||
return 120 * time.Hour, nil
|
||
case "1W":
|
||
return 168 * time.Hour, nil
|
||
case "1M":
|
||
return 720 * time.Hour, nil // 假设 30 天
|
||
default:
|
||
return 0, fmt.Errorf("unsupported period: %s", period)
|
||
}
|
||
}
|