Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

369
cmd/ingest/main.go Normal file
View 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())
}
}