Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
369
cmd/ingest/main.go
Normal file
369
cmd/ingest/main.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user