Files
dennis ea5b24d0cf 1.改为多线程抓取数据
2.数据库表名重定义,股票代码有可能命中PostgreSQL的保留3.关键字,加市场后缀,如US或HK
4.删除了插入数据时存在检查以提高效率
5.将 io/ioutil 的使用移除,并替换为 Go 1.16+ 推荐的实现
2025-10-31 01:23:54 +08:00

344 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"getk/appconfig"
"getk/dbconn"
"github.com/longportapp/openapi-go/config"
"github.com/longportapp/openapi-go/quote"
)
type Candlestick struct {
Symbol string
Timestamp time.Time
Open float64
Close float64
High float64
Low float64
Volume int64
Turnover float64
}
type job struct {
symbol string
date time.Time
}
// 使用 appconfig.AccountConfig 的类型别名,避免重复定义
type AccountConfig = appconfig.AccountConfig
type RetryConfig struct {
MaxAttempts int
BaseDelayMS int
MaxDelayMS int
}
type FetchResult struct {
Account string
Worker int
Symbol string
Ok bool
Err error
Records int
Elapsed time.Duration
}
// 统一生成安全的表名,避免与保留关键字冲突
func safeTableName(symbol string) string {
base := strings.Split(symbol, ".")[0]
region := strings.Split(symbol, ".")[1]
name := strings.ToLower(base)
regionName := strings.ToLower(region)
return name + "_" + regionName
}
// 自动创建数据表(若不存在)
func EnsureTable(db *sql.DB, symbol string) error {
tableName := safeTableName(symbol)
createSQL := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
timestamp TIMESTAMPTZ PRIMARY KEY,
open DOUBLE PRECISION,
close DOUBLE PRECISION,
high DOUBLE PRECISION,
low DOUBLE PRECISION,
volume BIGINT,
turnover DOUBLE PRECISION
)`, tableName)
_, err := db.Exec(createSQL)
return err
}
// getPeriodFromConfig 根据配置字符串返回对应的Period类型
func getPeriodFromConfig(periodStr string) quote.Period {
switch periodStr {
case "OneMinute":
return quote.PeriodOneMinute
case "FiveMinute":
return quote.PeriodFiveMinute
case "FifteenMinute":
return quote.PeriodFifteenMinute
case "ThirtyMinute":
return quote.PeriodThirtyMinute
default:
return quote.PeriodOneMinute
}
}
// getAdjustTypeFromConfig 根据配置字符串返回对应的AdjustType类型
func getAdjustTypeFromConfig(adjustStr string) quote.AdjustType {
switch adjustStr {
case "No":
return quote.AdjustTypeNo
case "ForwardAdjust":
return quote.AdjustTypeForward
default:
return quote.AdjustTypeNo
}
}
// configPath 返回与可执行文件同级的 config 目录下的文件路径
func configPath(filename string) string {
// 1) 优先使用环境变量 CONFIG_DIR(可设置为绝对或相对目录)
if dir := os.Getenv("CONFIG_DIR"); dir != "" {
return filepath.Join(dir, filename)
}
// 2) 优先尝试当前工作目录下的 config 目录(兼容 go run)
if wd, err := os.Getwd(); err == nil {
p := filepath.Join(wd, "config", filename)
if _, err := os.Stat(p); err == nil {
return p
}
}
// 3) 回退到可执行文件所在目录的 config 目录(兼容已编译二进制的部署场景)
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
p := filepath.Join(dir, "config", filename)
if _, err := os.Stat(p); err == nil {
return p
}
}
// 4) 最后回退到相对路径,交由后续读取时报错并提示
return filepath.Join(".", "config", filename)
}
func InsertCandlesticks(db *sql.DB, symbol string, candlesticks []Candlestick) error {
tableName := safeTableName(symbol)
insertStmt, err := db.Prepare(fmt.Sprintf(
`INSERT INTO %s (timestamp, open, close, high, low, volume, turnover)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (timestamp) DO NOTHING`,
tableName,
))
if err != nil {
return err
}
defer insertStmt.Close()
for _, c := range candlesticks {
_, err := insertStmt.Exec(c.Timestamp, c.Open, c.Close, c.High, c.Low, c.Volume, c.Turnover)
if err != nil {
log.Printf("Insert failed: %v\n", err)
}
}
return nil
}
func main() {
// 加载应用配置
appCfg, err := appconfig.Load(configPath("config.yaml"))
if err != nil {
log.Fatalf("读取应用配置失败: %v", err)
}
// 解析日期
dates, err := appCfg.ParseDates()
if err != nil {
log.Fatalf("解析日期失败: %v", err)
}
// 通过独立包加载数据库配置并建立连接
dbCfg, err := dbconn.Load(configPath("db.yaml"))
if err != nil {
log.Fatalf("读取数据库配置失败: %v", err)
}
db, err := dbconn.Open(dbCfg)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 获取期间类型
period := getPeriodFromConfig(appCfg.Settings.Period)
adjustType := getAdjustTypeFromConfig(appCfg.Settings.AdjustType)
fmt.Printf("开始数据获取任务...\n")
fmt.Printf("配置的股票数量: %d\n", len(appCfg.Symbols))
fmt.Printf("配置的日期数量: %d\n", len(dates))
fmt.Printf("总任务数: %d\n\n", len(appCfg.Symbols)*len(dates))
// 预先确保所有表存在(可并发安全,若不存在则创建)
for _, symbol := range appCfg.Symbols {
if err := EnsureTable(db, symbol); err != nil {
fmt.Printf(" 创建表失败: %v\n", err)
}
}
// 单账号模式:加载 longport.yaml
acc, err := appconfig.LoadLongportAccount(configPath("longport.yaml"))
if err != nil {
log.Fatalf("读取 longport.yaml 失败: %v", err)
}
// 创建单账号的 QuoteContext(通过临时配置文件)
tmpPath := filepath.Join(os.TempDir(), "longport_single.yaml")
content := fmt.Sprintf(
"longport:\n app_key: %q\n app_secret: %q\n access_token: %q\n region: %q\n",
acc.AppKey, acc.AppSecret, acc.AccessToken, acc.Region,
)
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
log.Fatalf("写入临时配置失败: %v", err)
}
confAcc, err := config.New(config.WithFilePath(tmpPath))
if err != nil {
log.Fatalf("加载行情配置失败: %v", err)
}
accCtx, err := quote.NewFromCfg(confAcc)
if err != nil {
log.Fatalf("创建行情上下文失败: %v", err)
}
defer accCtx.Close()
// 重试策略(用于 SDK 拉取失败时的指数退避重试)
retryCfg := RetryConfig{MaxAttempts: 3, BaseDelayMS: 500, MaxDelayMS: 2000}
// 构建任务队列(所有 symbols x dates)
totalTasks := len(appCfg.Symbols) * len(dates)
var currentTask int64
jobs := make(chan job, totalTasks)
for _, symbol := range appCfg.Symbols {
for _, date := range dates {
jobs <- job{symbol: symbol, date: date}
}
}
close(jobs)
// 账号级速率限制器
rps := acc.RPS
if rps <= 0 {
rps = 10
}
interval := time.Second / time.Duration(rps)
limiter := time.NewTicker(interval)
defer limiter.Stop()
// 启动 worker 池
threads := acc.Threads
if threads <= 0 {
threads = 5
}
var wg sync.WaitGroup
var successCount int64
var failCount int64
wg.Add(threads)
for w := 0; w < threads; w++ {
workerID := w + 1
go func(workerID int, jobs <-chan job) {
defer wg.Done()
for j := range jobs {
t0 := time.Now()
<-limiter.C
cur := atomic.AddInt64(&currentTask, 1)
fmt.Printf(" [进度=%d/%d] 线程=%d 正在查询 %s 的 %s 数据...\n", cur, totalTasks, workerID, j.symbol, j.date.Format("2006-01-02"))
start := time.Date(j.date.Year(), j.date.Month(), j.date.Day(), 0, 0, 0, 0, time.UTC)
end := time.Date(j.date.Year(), j.date.Month(), j.date.Day(), 23, 59, 59, 0, time.UTC)
var fetched bool
for attempt := 1; attempt <= retryCfg.MaxAttempts; attempt++ {
candlesticks, err := accCtx.HistoryCandlesticksByDate(
context.Background(),
j.symbol,
period,
adjustType,
&start,
&end,
)
if err != nil {
if attempt < retryCfg.MaxAttempts {
base := retryCfg.BaseDelayMS * (1 << (attempt - 1))
if base > retryCfg.MaxDelayMS {
base = retryCfg.MaxDelayMS
}
backoff := time.Duration(base) * time.Millisecond
fmt.Printf(" 线程=%d 重试 %d/%d (错误: %v, 等待=%dms)\n", workerID, attempt, retryCfg.MaxAttempts, err, backoff.Milliseconds())
time.Sleep(backoff)
continue
}
elapsed := time.Since(t0)
atomic.AddInt64(&failCount, 1)
fmt.Printf(" 线程=%d 失败 (耗时=%dms): %v\n", workerID, elapsed.Milliseconds(), err)
break
}
var records []Candlestick
for _, c := range candlesticks {
if c.Open == nil || c.Close == nil || c.High == nil || c.Low == nil {
continue
}
turnover := 0.0
if c.Turnover != nil {
turnover = c.Turnover.InexactFloat64()
}
records = append(records, Candlestick{
Symbol: j.symbol,
Timestamp: time.Unix(c.Timestamp, 0),
Open: c.Open.InexactFloat64(),
Close: c.Close.InexactFloat64(),
High: c.High.InexactFloat64(),
Low: c.Low.InexactFloat64(),
Volume: c.Volume,
Turnover: turnover,
})
}
if err := InsertCandlesticks(db, j.symbol, records); err != nil {
elapsed := time.Since(t0)
fmt.Printf(" 线程=%d 数据库插入失败 (耗时=%dms): %v\n", workerID, elapsed.Milliseconds(), err)
atomic.AddInt64(&failCount, 1)
break
}
elapsed := time.Since(t0)
fmt.Printf(" 线程=%d 完成 (记录=%d, 耗时=%dms)\n", workerID, len(records), elapsed.Milliseconds())
atomic.AddInt64(&successCount, 1)
fetched = true
break
}
if !fetched {
// 已在失败路径计数与打印
}
}
}(workerID, jobs)
}
wg.Wait()
fmt.Printf(" 所有数据已保存到PostgreSQL!\n")
fmt.Printf("成功=%d 失败=%d 总任务=%d\n", successCount, failCount, totalTasks)
}