- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
370 lines
12 KiB
Go
370 lines
12 KiB
Go
// ingest 是可转债历史K线入库命令:
|
||
//
|
||
// go run ./cmd/ingest -code 110062 -from 2020 [-source eastmoney|tencent]
|
||
// go run ./cmd/ingest -latest 10 [-kline-source tencent]
|
||
// go run ./cmd/ingest -all [-source eastmoney] // 全市场转债历史数据入库
|
||
// go run ./cmd/ingest -incr // 增量同步:从库中最后日期起拉取最新数据
|
||
// go run ./cmd/ingest -incr -since 20260101 // 增量同步:从指定日期起拉取
|
||
// go run ./cmd/ingest -incr -all -limit 10 // 增量同步:仅处理前 10 只已入库转债
|
||
//
|
||
// 分批拉取可转债全量日K线,幂等写入 SQLite(默认 %LOCALAPPDATA%\stock-analysis\stock.db)。
|
||
// 默认使用东财行情源;东财 push2 接口被限流时可切换 tencent 备用源。
|
||
// 客户端内置 2s 请求间隔 + 多域名轮询 + 重试,遵循公开接口限速要求。
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"time"
|
||
|
||
"stock-analysis/internal/datasource"
|
||
"stock-analysis/internal/datasource/eastmoney"
|
||
"stock-analysis/internal/datasource/fallback"
|
||
"stock-analysis/internal/datasource/tencent"
|
||
"stock-analysis/internal/model"
|
||
"stock-analysis/internal/service"
|
||
"stock-analysis/internal/storage/sqlite"
|
||
)
|
||
|
||
func main() {
|
||
code := flag.String("code", "110062", "可转债代码(默认烽火转债)")
|
||
fromYear := flag.Int("from", 2020, "起始年份(默认 2020)")
|
||
latest := flag.Int("latest", 0, "批量拉取最新上市的前 N 只可转债入库(设置后忽略 -code/-from)")
|
||
all := flag.Bool("all", false, "拉取全市场可转债历史数据入库(设置后忽略其它参数)")
|
||
limit := flag.Int("limit", 0, "-all / -incr -all 模式下最多处理的转债数量(0 表示不限,用于小范围测试)")
|
||
incr := flag.Bool("incr", false, "增量同步模式:从库中最后日期(或 -since 指定日期)起拉取最新K线入库")
|
||
since := flag.String("since", "", "增量同步起点(YYYYMMDD,如 20260101);不指定则自动取库中最后日期的次日")
|
||
dbPath := flag.String("db", defaultDBPath(), "SQLite 数据库文件路径")
|
||
source := flag.String("source", "eastmoney", "K线数据源:eastmoney(东财)/ tencent(腾讯备用)")
|
||
flag.Parse()
|
||
|
||
var err error
|
||
switch {
|
||
case *incr:
|
||
err = runIncremental(*code, *all, *since, *limit, *dbPath, *source)
|
||
case *all:
|
||
err = runAll(*dbPath, *source, *limit)
|
||
case *latest > 0:
|
||
err = runLatest(*latest, *dbPath, *source)
|
||
default:
|
||
err = run(*code, *fromYear, *dbPath, *source)
|
||
}
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "ingest error:", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// run 执行单只转债的分批拉取并入库。
|
||
func run(code string, fromYear int, dbPath, source string) error {
|
||
store, err := openStore(dbPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer store.Close()
|
||
|
||
svc := service.NewMarketService(newDatasource(source))
|
||
ctx := context.Background()
|
||
|
||
fmt.Printf("[%s] 分批拉取 %s 历史日K(%d 年至今)...\n", source, code, fromYear)
|
||
klines, err := svc.FetchAllDailyKline(ctx, code, fromYear)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(klines) == 0 {
|
||
return fmt.Errorf("未获取到 %s 的历史数据", code)
|
||
}
|
||
fmt.Printf("拉取完成:共 %d 根(%s ~ %s)\n", len(klines), klines[0].Date, klines[len(klines)-1].Date)
|
||
|
||
if err := store.SaveKlines(ctx, code, klines); err != nil {
|
||
return err
|
||
}
|
||
return printCount(ctx, store, code)
|
||
}
|
||
|
||
// runLatest 批量拉取最新上市的前 n 只可转债并入库。
|
||
// 列表来源:东财 datacenter;K线来源:-source 指定(默认 eastmoney,可用 tencent)。
|
||
func runLatest(n int, dbPath, source string) error {
|
||
store, err := openStore(dbPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer store.Close()
|
||
|
||
ctx := context.Background()
|
||
bonds, err := eastmoney.NewClient().FetchLatestBonds(ctx, n)
|
||
if err != nil {
|
||
return fmt.Errorf("获取最新转债列表: %w", err)
|
||
}
|
||
|
||
svc := service.NewMarketService(newDatasource(source))
|
||
var okCount, failCount int
|
||
for _, b := range bonds {
|
||
fromYear := yearOf(b.ListDate)
|
||
klines, err := svc.FetchAllDailyKline(ctx, b.Code, fromYear)
|
||
if err != nil || len(klines) == 0 {
|
||
failCount++
|
||
fmt.Printf(" %s %s 上市 %s: 无数据(%v)\n", b.Code, b.Name, b.ListDate, err)
|
||
continue
|
||
}
|
||
if err := store.SaveKlines(ctx, b.Code, klines); err != nil {
|
||
failCount++
|
||
fmt.Printf(" %s %s: 入库失败 %v\n", b.Code, b.Name, err)
|
||
continue
|
||
}
|
||
okCount++
|
||
fmt.Printf(" %s %s 上市 %s: %d 根(%s ~ %s)\n", b.Code, b.Name, b.ListDate,
|
||
len(klines), klines[0].Date, klines[len(klines)-1].Date)
|
||
}
|
||
fmt.Printf("批量入库完成:成功 %d 只,失败 %d 只(%s)\n", okCount, failCount, dbPath)
|
||
return nil
|
||
}
|
||
|
||
// runAll 拉取全市场可转债的历史数据入库。
|
||
// 流程:全量列表 → 过滤未上市/停牌 → 逐只单次请求全历史K线 → 幂等写入。
|
||
// 已入库的转债自动跳过(断点续跑),失败项会汇总列出,可重跑补齐。
|
||
func runAll(dbPath, source string, limit int) error {
|
||
store, err := openStore(dbPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer store.Close()
|
||
|
||
ctx := context.Background()
|
||
ds := newDatasource(source)
|
||
svc := service.NewMarketService(ds)
|
||
|
||
fmt.Println("拉取全量转债代码列表(datacenter)...")
|
||
bonds, err := eastmoney.NewClient().FetchAllBondCodes(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("获取转债列表: %w", err)
|
||
}
|
||
|
||
// datacenter 列表包含已退市债(K线接口对其返回空,拉取时跳过)。
|
||
var candidates []model.ConvertibleBond
|
||
for _, b := range bonds {
|
||
if b.ListDate == "" {
|
||
continue
|
||
}
|
||
candidates = append(candidates, b)
|
||
}
|
||
fmt.Printf("列表共 %d 只(含退市,无发行日期已过滤 %d 只)\n", len(candidates), len(bonds)-len(candidates))
|
||
|
||
var okCount, skipCount, failCount int
|
||
var failed []string
|
||
for i, b := range candidates {
|
||
if limit > 0 && i >= limit {
|
||
fmt.Printf("达到 -limit %d 限制,提前结束\n", limit)
|
||
break
|
||
}
|
||
// 断点续跑:已入库的跳过。
|
||
if n, err := store.CountKlines(ctx, b.Code); err == nil && n > 0 {
|
||
skipCount++
|
||
fmt.Printf("[%3d/%d] %s %s 已入库(%d 根),跳过\n", i+1, len(candidates), b.Code, b.Name, n)
|
||
continue
|
||
}
|
||
|
||
klines, err := svc.FetchFullKline(ctx, b.Code, yearOf(b.ListDate))
|
||
if err != nil || len(klines) == 0 {
|
||
failCount++
|
||
failed = append(failed, b.Code)
|
||
fmt.Printf("[%3d/%d] %s %s: 无数据(%v)\n", i+1, len(candidates), b.Code, b.Name, err)
|
||
continue
|
||
}
|
||
if err := store.SaveKlines(ctx, b.Code, klines); err != nil {
|
||
failCount++
|
||
failed = append(failed, b.Code)
|
||
fmt.Printf("[%3d/%d] %s %s: 入库失败 %v\n", i+1, len(candidates), b.Code, b.Name, err)
|
||
continue
|
||
}
|
||
okCount++
|
||
fmt.Printf("[%3d/%d] %s %s: %d 根(%s ~ %s)\n", i+1, len(candidates), b.Code, b.Name,
|
||
len(klines), klines[0].Date, klines[len(klines)-1].Date)
|
||
}
|
||
fmt.Printf("全量入库完成:成功 %d,跳过 %d,失败 %d(库: %s)\n", okCount, skipCount, failCount, dbPath)
|
||
if len(failed) > 0 {
|
||
fmt.Println("失败清单(可重跑续传):", failed)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// openStore 打开 SQLite 存储,确保目录存在。
|
||
func openStore(dbPath string) (*sqlite.Store, error) {
|
||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
return sqlite.Open(dbPath)
|
||
}
|
||
|
||
// runIncremental 增量同步:从库中最后日期(或 -since 指定日期)起,拉取最新K线入库。
|
||
//
|
||
// 模式:
|
||
// - 单只:-incr -code 113667 [-since 20260101]
|
||
// - 全量:-incr -all [-limit N] [-since 20260101]:对库中所有已入库转债做增量
|
||
//
|
||
// 起点确定(优先级):
|
||
// 1. -since 指定日期,作为该日期起的覆盖式拉取(含当日)
|
||
// 2. 自动取该转债在库中最后日期的次日,避免重复请求
|
||
//
|
||
// 配合 SaveKlines 的幂等语义(ON CONFLICT DO NOTHING),即使 -since 与已存档区间重叠也不会破坏数据。
|
||
func runIncremental(code string, all bool, since string, limit int, dbPath, source string) error {
|
||
store, err := openStore(dbPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer store.Close()
|
||
|
||
ctx := context.Background()
|
||
svc := service.NewMarketService(newDatasource(source))
|
||
|
||
if !all {
|
||
// 单只增量同步
|
||
n, err := incrementalOne(ctx, store, svc, code, since)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n < 0 {
|
||
fmt.Printf("%s 库中无存档数据,跳过(请先用 -all 全量入库)\n", code)
|
||
} else if n == 0 {
|
||
fmt.Printf("%s 已是最新,无需更新\n", code)
|
||
} else {
|
||
fmt.Printf("%s 增量入库 %d 根\n", code, n)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 全量增量:遍历库中已存档的所有转债代码
|
||
codes, err := store.ListCodes(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("加载库中转债列表: %w", err)
|
||
}
|
||
fmt.Printf("增量同步开始:共 %d 只已入库转债\n", len(codes))
|
||
|
||
var okCount, skipCount, failCount int
|
||
var failed []string
|
||
for i, c := range codes {
|
||
if limit > 0 && i >= limit {
|
||
fmt.Printf("达到 -limit %d 限制,提前结束\n", limit)
|
||
break
|
||
}
|
||
n, err := incrementalOne(ctx, store, svc, c, since)
|
||
switch {
|
||
case err != nil:
|
||
failCount++
|
||
failed = append(failed, c)
|
||
fmt.Printf("[%3d/%d] %s: 失败 %v\n", i+1, len(codes), c, err)
|
||
case n < 0:
|
||
skipCount++ // 无存档日期或起点过晚,已跳过
|
||
default:
|
||
okCount++
|
||
if n > 0 {
|
||
fmt.Printf("[%3d/%d] %s: 增量入库 %d 根\n", i+1, len(codes), c, n)
|
||
} else {
|
||
skipCount++
|
||
}
|
||
}
|
||
}
|
||
fmt.Printf("增量同步完成:成功 %d,跳过 %d,失败 %d(库: %s)\n", okCount, skipCount, failCount, dbPath)
|
||
if len(failed) > 0 {
|
||
fmt.Println("失败清单(可重跑续传):", failed)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// incrementalOne 对单只转债做增量同步。
|
||
// 返回:拉取到的K线条数(>=0);-1 表示跳过(库中无存档日期)。
|
||
// 注:由于 SaveKlines 幂等语义(ON CONFLICT DO NOTHING),返回的条数可能包含已存档的日期;
|
||
// 但因 beg 已是「最后日期的次日」,正常情况下返回值即新增条数。
|
||
func incrementalOne(ctx context.Context, store *sqlite.Store, svc *service.MarketService, code, since string) (int, error) {
|
||
beg, err := resolveIncrementalBeg(ctx, store, code, since)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if beg == "" {
|
||
return -1, nil
|
||
}
|
||
|
||
klines, err := svc.FetchIncrementalKline(ctx, code, beg)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if len(klines) == 0 {
|
||
return 0, nil
|
||
}
|
||
if err := store.SaveKlines(ctx, code, klines); err != nil {
|
||
return 0, err
|
||
}
|
||
return len(klines), nil
|
||
}
|
||
|
||
// resolveIncrementalBeg 确定增量拉取的起始日期(YYYYMMDD)。
|
||
//
|
||
// -since 指定 → 直接使用(覆盖库中最后日期)
|
||
// 未指定 → 取库中最后日期的次日;无记录返回空串(表示该转债无存档,应跳过)
|
||
func resolveIncrementalBeg(ctx context.Context, store *sqlite.Store, code, since string) (string, error) {
|
||
if since != "" {
|
||
return since, nil
|
||
}
|
||
lastDate, err := store.LastKlineDate(ctx, code)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if lastDate == "" {
|
||
return "", nil
|
||
}
|
||
// lastDate 格式 YYYY-MM-DD,转 YYYYMMDD 并 +1 天,避免重复请求当天数据
|
||
t, err := time.Parse("2006-01-02", lastDate)
|
||
if err != nil {
|
||
return "", fmt.Errorf("解析最后日期 %s: %w", lastDate, err)
|
||
}
|
||
return t.AddDate(0, 0, 1).Format("20060102"), nil
|
||
}
|
||
|
||
// defaultDBPath 返回默认数据库路径。
|
||
// 项目位于 OneDrive 同步目录,SQLite 写入会受 OneDrive 文件操作限制,
|
||
// 故默认存放到本地应用数据目录(%LOCALAPPDATA%\stock-analysis\stock.db)。
|
||
func defaultDBPath() string {
|
||
local := os.Getenv("LOCALAPPDATA")
|
||
if local == "" {
|
||
local = os.TempDir()
|
||
}
|
||
return filepath.Join(local, "stock-analysis", "stock.db")
|
||
}
|
||
|
||
// printCount 打印库中指定代码的K线总数。
|
||
func printCount(ctx context.Context, store *sqlite.Store, code string) error {
|
||
total, err := store.CountKlines(ctx, code)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("入库成功:库中 %s 共 %d 根日K\n", code, total)
|
||
return nil
|
||
}
|
||
|
||
// yearOf 从上市日期(2006-01-02)解析年份。
|
||
func yearOf(listDate string) int {
|
||
if len(listDate) >= 4 {
|
||
if y, err := strconv.Atoi(listDate[:4]); err == nil {
|
||
return y
|
||
}
|
||
}
|
||
return 2026
|
||
}
|
||
|
||
// newDatasource 根据名称创建K线数据源。
|
||
// 默认使用东财为主数据源,腾讯为备用数据源(东财失败时自动切换)。
|
||
func newDatasource(name string) datasource.MarketDataSource {
|
||
switch name {
|
||
case "tencent":
|
||
return tencent.NewClient()
|
||
case "eastmoney":
|
||
return fallback.New(eastmoney.NewClient(), tencent.NewClient())
|
||
default:
|
||
return fallback.New(eastmoney.NewClient(), tencent.NewClient())
|
||
}
|
||
}
|