Files
stock-analysis/cmd/ingest/main.go
WarrenZhou e28bfec957 Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
2026-08-17 20:23:22 +08:00

370 lines
12 KiB
Go
Raw 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.

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