// 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()) } }