commit e28bfec957c70b774244abe40e21d4daa0aae166 Author: WarrenZhou Date: Mon Aug 17 20:23:22 2026 +0800 Initial commit: 可转债轮动分析系统 - 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a343cde --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 编译产物 +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Go 测试与覆盖率 +*.test +*.out +coverage.txt + +# 数据库与数据文件 +*.db +*.db-journal +*.sqlite +*.sqlite3 + +# 日志 +*.log + +# IDE 配置 +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 临时文件 +tmp_* +*.tmp + +# 构建输出 +/bin/ +/dist/ diff --git a/cmd/backtest/main.go b/cmd/backtest/main.go new file mode 100644 index 0000000..c06ffa9 --- /dev/null +++ b/cmd/backtest/main.go @@ -0,0 +1,258 @@ +// backtest 是可转债轮动策略回测命令。 +// +// go run ./cmd/backtest [-db 库路径] [-from 2020-01-01] [-to 2025-12-31] \ +// [-strategy lowprice|doublelow] [-topn 10] [-rebalance 21] \ +// [-min-price 80] [-max-price 130] [-max-premium 30] [-cash 100000] [-out 曲线.csv] +// +// 从 SQLite 加载全量转债日K线,运行内置轮动策略(低价/双低), +// 输出绩效指标并与等权全转债基准对比。 +package main + +import ( + "context" + "encoding/csv" + "flag" + "fmt" + "os" + "path/filepath" + + "stock-analysis/internal/backtest" + "stock-analysis/internal/strategy" + "stock-analysis/internal/storage/sqlite" +) + +func main() { + dbPath := flag.String("db", defaultDBPath(), "SQLite 数据库文件路径") + from := flag.String("from", "2020-01-01", "回测起始日(含)") + to := flag.String("to", "", "回测结束日(含,默认数据末日)") + topn := flag.Int("topn", 10, "持仓数量") + rebalance := flag.Int("rebalance", 21, "调仓周期(交易日)") + minPrice := flag.Float64("min-price", 80, "价格下限") + maxPrice := flag.Float64("max-price", 130, "价格上限") + strategyName := flag.String("strategy", "lowprice", "策略: lowprice(低价轮动)|doublelow(双低轮动)|premium(溢价率轮动)") + maxPremium := flag.Float64("max-premium", 30, "双低策略溢价率上限") + fundamental := flag.Bool("fundamental", false, "启用正股基本面过滤器(排除ST/高负债/亏损标的)") + cash := flag.Float64("cash", 100000, "初始资金(元)") + out := flag.String("out", "", "净值曲线 CSV 输出路径(可选)") + flag.Parse() + + ctx := context.Background() + store, err := sqlite.Open(*dbPath) + if err != nil { + fatal("打开数据库失败: ", err) + } + defer store.Close() + + feed, err := backtest.NewDataFeed(ctx, store) + if err != nil { + fatal("加载K线数据失败: ", err) + } + fmt.Printf("数据加载完成:%d 个交易日(%s ~ %s)\n\n", + len(feed.Dates()), feed.Dates()[0], feed.Dates()[len(feed.Dates())-1]) + + params := backtest.DefaultParams() + params.InitialCash = *cash + params.StartDate = *from + params.EndDate = *to + + var strat backtest.Strategy + switch *strategyName { + case "lowprice": + strat = strategy.NewLowPrice(strategy.LowPriceParams{ + TopN: *topn, + Rebalance: *rebalance, + MinPrice: *minPrice, + MaxPrice: *maxPrice, + }) + case "doublelow": + strat = strategy.NewDoubleLow(strategy.DoubleLowParams{ + TopN: *topn, + Rebalance: *rebalance, + MinPrice: *minPrice, + MaxPrice: *maxPrice, + MaxPremium: *maxPremium, + }) + case "premium": + strat = strategy.NewPremium(strategy.PremiumParams{ + TopN: *topn, + Rebalance: *rebalance, + MinPrice: *minPrice, + MaxPrice: *maxPrice, + }) + default: + fatal("未知策略 ", fmt.Errorf("应为 lowprice、doublelow 或 premium")) + } + + engine := backtest.NewEngine(params, strat) + + if *fundamental { + // 加载正股基本面数据 + fundamentals, err := store.LoadAllFundamentals(ctx) + if err != nil { + fatal("加载基本面数据失败: ", err) + } + // 获取正股-转债映射 + stock2bond, err := store.GetStockBondMap(ctx) + if err != nil { + fatal("获取正股-转债映射失败: ", err) + } + filter := backtest.NewFundamentalFilter(stock2bond) + engine.SetFundamentalFilter(filter, fundamentals) + fmt.Printf("基本面过滤器已启用:%d 只正股数据, %d 条正股-转债映射\n", len(fundamentals), len(stock2bond)) + } + + res, err := engine.Run(ctx, feed) + if err != nil { + fatal("回测失败: ", err) + } + fmt.Printf("策略:%s(%s ~ %s)\n", res.Strategy, res.Start, res.End) + printMetrics("策略", res.Metrics) + + bench := computeBenchmark(feed, *from, *to) + fmt.Println() + fmt.Printf("基准:等权全转债指数(%s ~ %s)\n", *from, *to) + printMetrics("基准", bench) + + if *out != "" { + if err := writeCSV(*out, res, feed); err != nil { + fatal("写CSV失败: ", err) + } + fmt.Printf("\n净值曲线已输出到 %s\n", *out) + } +} + +// computeBenchmark 计算等权全转债指数:每日对全部存续转债取等权平均日收益并复利。 +func computeBenchmark(feed *backtest.DataFeed, from, to string) backtest.Metrics { + var ( + idx float64 = 1 + prev map[string]float64 + curve []backtest.EquityPoint + hasPrev bool + ) + for _, date := range feed.Dates() { + if from != "" && date < from { + continue + } + if to != "" && date > to { + break + } + cur := make(map[string]float64) + for _, q := range feed.QuotesOn(date) { + cur[q.Code] = q.Price + } + if hasPrev { + var sum, n float64 + for code, p := range cur { + if pp, ok := prev[code]; ok && pp > 0 { + sum += p/pp - 1 + n++ + } + } + if n > 0 { + idx *= 1 + sum/n + } + } + curve = append(curve, backtest.EquityPoint{Date: date, Value: idx, Return: idx - 1}) + prev = cur + hasPrev = true + } + return backtest.ComputeMetrics(curve) +} + +// printMetrics 打印绩效指标表格。 +func printMetrics(label string, m backtest.Metrics) { + fmt.Printf("%-14s 总收益 %9.2f%% 年化 %8.2f%% 最大回撤 %7.2f%% 夏普 %6.2f 波动率 %5.2f%% 胜率 %5.1f%%\n", + label, + m.TotalReturn*100, m.AnnualReturn*100, m.MaxDrawdown*100, + m.Sharpe, m.Volatility*100, m.WinRatio*100) +} + +// writeCSV 输出策略与基准的净值曲线(含累计收益)到 CSV 文件。 +func writeCSV(path string, res *backtest.Result, feed *backtest.DataFeed) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + if err := w.Write([]string{"date", "strategy_value", "strategy_return", "bench_value", "bench_return"}); err != nil { + return err + } + + // 基准曲线独立重算,与策略日期对齐。 + benchCurve := benchmarkCurve(feed, res.Start, res.End) + byDate := make(map[string]backtest.EquityPoint, len(benchCurve)) + for _, p := range benchCurve { + byDate[p.Date] = p + } + + for _, p := range res.Curve { + b, ok := byDate[p.Date] + bv, br := 1.0, 0.0 + if ok { + bv, br = b.Value, b.Return + } + if err := w.Write([]string{ + p.Date, + fmt.Sprintf("%.2f", p.Value), + fmt.Sprintf("%.6f", p.Return), + fmt.Sprintf("%.6f", bv), + fmt.Sprintf("%.6f", br), + }); err != nil { + return err + } + } + return nil +} + +// benchmarkCurve 返回 from~to 区间的基准净值曲线。 +func benchmarkCurve(feed *backtest.DataFeed, from, to string) []backtest.EquityPoint { + var ( + idx float64 = 1 + prev map[string]float64 + curve []backtest.EquityPoint + hasPrev bool + ) + for _, date := range feed.Dates() { + if date < from || (to != "" && date > to) { + continue + } + cur := make(map[string]float64) + for _, q := range feed.QuotesOn(date) { + cur[q.Code] = q.Price + } + if hasPrev { + var sum, n float64 + for code, p := range cur { + if pp, ok := prev[code]; ok && pp > 0 { + sum += p/pp - 1 + n++ + } + } + if n > 0 { + idx *= 1 + sum/n + } + } + curve = append(curve, backtest.EquityPoint{Date: date, Value: idx, Return: idx - 1}) + prev = cur + hasPrev = true + } + return curve +} + +// defaultDBPath 与 ingest 保持一致,返回默认数据库路径。 +func defaultDBPath() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + local = os.TempDir() + } + return filepath.Join(local, "stock-analysis", "stock.db") +} + +// fatal 打印错误并退出。 +func fatal(msg string, err error) { + fmt.Fprintln(os.Stderr, msg, err) + os.Exit(1) +} diff --git a/cmd/dbstats/main.go b/cmd/dbstats/main.go new file mode 100644 index 0000000..af5e23f --- /dev/null +++ b/cmd/dbstats/main.go @@ -0,0 +1,41 @@ +// dbstats 输出 SQLite 库的转债数与K线总数统计。 +// +// go run ./cmd/dbstats [-db 库路径] +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "os" + + _ "modernc.org/sqlite" +) + +func main() { + dbPath := flag.String("db", "C:\\Users\\zhouww\\AppData\\Local\\Temp\\stock-analysis-data\\stock.db", "SQLite 数据库文件路径") + flag.Parse() + + db, err := sql.Open("sqlite", *dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer db.Close() + db.SetMaxOpenConns(1) + ctx := context.Background() + + var bonds, rows int + if err := db.QueryRowContext(ctx, "SELECT COUNT(DISTINCT code), COUNT(*) FROM convertible_kline").Scan(&bonds, &rows); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("转债数: %d,K线总数: %d(库: %s)\n", bonds, rows, *dbPath) + + // 数据范围抽查。 + var minDate, maxDate string + if err := db.QueryRowContext(ctx, "SELECT MIN(date), MAX(date) FROM convertible_kline").Scan(&minDate, &maxDate); err == nil { + fmt.Printf("K线日期范围: %s ~ %s\n", minDate, maxDate) + } +} diff --git a/cmd/fetch/main.go b/cmd/fetch/main.go new file mode 100644 index 0000000..620828f --- /dev/null +++ b/cmd/fetch/main.go @@ -0,0 +1,85 @@ +// fetch 是数据拉取验证命令(CLI): +// +// go run ./cmd/fetch # 拉取全量可转债实时行情 +// go run ./cmd/fetch -code 113667 # 拉取指定可转债历史K线 +package main + +import ( + "context" + "flag" + "fmt" + "os" + "sort" + + "stock-analysis/internal/datasource/eastmoney" + "stock-analysis/internal/datasource/fallback" + "stock-analysis/internal/datasource/tencent" + "stock-analysis/internal/service" +) + +func main() { + code := flag.String("code", "", "可转债代码(如 113667),指定时拉取历史K线") + days := flag.Int("days", 30, "打印最近N根K线") + flag.Parse() + + ds := fallback.New(eastmoney.NewClient(), tencent.NewClient()) + svc := service.NewMarketService(ds) + ctx := context.Background() + + if *code != "" { + if err := fetchKline(ctx, svc, *code, *days); err != nil { + fmt.Fprintln(os.Stderr, "kline error:", err) + os.Exit(1) + } + return + } + if err := fetchConvertibles(ctx, svc); err != nil { + fmt.Fprintln(os.Stderr, "list error:", err) + os.Exit(1) + } +} + +// fetchConvertibles 拉取并打印全量可转债。 +func fetchConvertibles(ctx context.Context, svc *service.MarketService) error { + bonds, err := svc.ListConvertibles(ctx) + if err != nil { + return err + } + fmt.Printf("共拉取可转债 %d 只\n\n", len(bonds)) + + // 按溢价率升序展示前 15 只(低溢价候选池雏形)。 + sort.Slice(bonds, func(i, j int) bool { return bonds[i].PremiumRate < bonds[j].PremiumRate }) + fmt.Printf("%-8s %-10s %8s %7s %8s %8s %8s %8s %8s\n", + "代码", "名称", "价格", "涨跌%", "溢价率%", "转股价值", "转股价", "纯债价值", "正股") + for i, b := range bonds { + if i >= 15 { + break + } + fmt.Printf("%-8s %-10s %8.3f %7.2f %8.2f %8.2f %8.2f %8.2f %s\n", + b.Code, b.Name, b.Price, b.ChangePct, b.PremiumRate, + b.ConvertValue, b.ConvertPrice, b.PureBondVal, b.StockName) + } + fmt.Println("\n... (仅展示前15只,按溢价率升序)") + return nil +} + +// fetchKline 拉取并打印指定可转债的历史K线。 +func fetchKline(ctx context.Context, svc *service.MarketService, code string, days int) error { + klines, err := svc.GetDailyKline(ctx, code, "20200101") + if err != nil { + return err + } + fmt.Printf("代码 %s 共 %d 根日K(%s ~ %s)\n\n", + code, len(klines), klines[0].Date, klines[len(klines)-1].Date) + + start := len(klines) - days + if start < 0 { + start = 0 + } + fmt.Printf("%-12s %8s %8s %8s %8s %12s\n", "日期", "开盘", "收盘", "最高", "最低", "成交量") + for _, k := range klines[start:] { + fmt.Printf("%-12s %8.3f %8.3f %8.3f %8.3f %12d\n", + k.Date, k.Open, k.Close, k.High, k.Low, k.Volume) + } + return nil +} diff --git a/cmd/fundamentals/main.go b/cmd/fundamentals/main.go new file mode 100644 index 0000000..47190aa --- /dev/null +++ b/cmd/fundamentals/main.go @@ -0,0 +1,102 @@ +// fundamentals 是正股基本面数据入库命令: +// +// go run ./cmd/fundamentals [-db 库路径] +// +// 从 convert_bond 表读取所有正股代码,通过东财 clist 接口批量拉取 +// 基本面数据(ROE/资产负债率/净利润/ST状态),写入 stock_fundamental 表。 +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "stock-analysis/internal/datasource/eastmoney" + "stock-analysis/internal/storage/sqlite" +) + +func main() { + dbPath := flag.String("db", defaultDBPath(), "SQLite 数据库文件路径") + flag.Parse() + + store, err := sqlite.Open(*dbPath) + if err != nil { + fatal("打开数据库失败: ", err) + } + defer store.Close() + + ctx := context.Background() + + // 1. 获取所有正股代码。 + stock2bond, err := store.GetStockBondMap(ctx) + if err != nil { + fatal("获取正股映射失败: ", err) + } + if len(stock2bond) == 0 { + fmt.Println("convert_bond 表为空,请先执行 go run ./cmd/ingest -all 入库转债数据") + return + } + + stockCodes := make([]string, 0, len(stock2bond)) + for code := range stock2bond { + stockCodes = append(stockCodes, code) + } + fmt.Printf("正股数量:%d\n", len(stockCodes)) + + // 2. 分批拉取基本面数据(每批最多 200 只)。 + client := eastmoney.NewClient() + const batchSize = 200 + + var totalSaved, totalFail int + for i := 0; i < len(stockCodes); i += batchSize { + end := i + batchSize + if end > len(stockCodes) { + end = len(stockCodes) + } + batch := stockCodes[i:end] + + funds, err := client.FetchStockFundamentals(ctx, batch) + if err != nil { + fmt.Printf("批次 %d-%d 拉取失败: %v\n", i, end-1, err) + totalFail += len(batch) + continue + } + + for _, f := range funds { + f.UpdateDate = time.Now().Format("2006-01-02") + if err := store.SaveStockFundamental(ctx, f); err != nil { + fmt.Printf(" %s %s: 保存失败 %v\n", f.Code, f.Name, err) + totalFail++ + continue + } + totalSaved++ + } + + fmt.Printf("批次 %d-%d: 拉取 %d 只, 已保存 %d\n", i+1, end, len(batch), totalSaved) + + // 控制请求频率。 + if end < len(stockCodes) { + time.Sleep(2 * time.Second) + } + } + + fmt.Printf("\n基本面入库完成:成功 %d 只,失败 %d 只(库: %s)\n", totalSaved, totalFail, *dbPath) +} + +// defaultDBPath 返回默认数据库路径。 +func defaultDBPath() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + local = os.TempDir() + } + return filepath.Join(local, "stock-analysis", "stock.db") +} + +// fatal 打印错误并退出。 +func fatal(msg string, err error) { + fmt.Fprintln(os.Stderr, msg, err) + os.Exit(1) +} \ No newline at end of file diff --git a/cmd/ingest/main.go b/cmd/ingest/main.go new file mode 100644 index 0000000..58527de --- /dev/null +++ b/cmd/ingest/main.go @@ -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()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0f16fed --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,87 @@ +// server 是可转债轮动分析系统的 B/S 服务入口。 +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "stock-analysis/internal/controller" + "stock-analysis/internal/datasource/eastmoney" + "stock-analysis/internal/datasource/fallback" + "stock-analysis/internal/datasource/tencent" + "stock-analysis/internal/router" + "stock-analysis/internal/service" + "stock-analysis/internal/storage/sqlite" +) + +func main() { + addr := ":8080" + if p := os.Getenv("PORT"); p != "" { + addr = ":" + p + } + webDir := "web" + dbPath := defaultDBPath() + + // 东财为主数据源(实时行情+历史K线),腾讯为历史K线备用源(东财失败时自动切换) + ds := fallback.New(eastmoney.NewClient(), tencent.NewClient()) + svc := service.NewMarketService(ds) + mc := controller.NewMarketController(svc, dbPath) + + store, err := sqlite.Open(dbPath) + if err != nil { + log.Fatalf("open sqlite: %v", err) + } + defer store.Close() + authSvc := service.NewAuthService(store) + if err := authSvc.EnsureAdmin(context.Background()); err != nil { + log.Printf("ensure admin: %v", err) + } + ac := controller.NewAuthController(authSvc) + + handler := router.New(mc, ac, webDir) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + // 优雅退出:收到信号后关闭服务。 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + log.Printf("stock-analysis server listening on http://localhost%s", addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} + +// defaultDBPath 返回默认数据库路径。 +// 默认使用项目目录下的 stock.db,避免 OneDrive/AppData 的 I/O 限制。 +func defaultDBPath() string { + if env := os.Getenv("STOCK_DB_PATH"); env != "" { + return env + } + exe, err := os.Executable() + if err != nil { + return "stock.db" + } + return filepath.Join(filepath.Dir(exe), "stock.db") +} diff --git a/docs/可行性分析.md b/docs/可行性分析.md new file mode 100644 index 0000000..e9939c3 --- /dev/null +++ b/docs/可行性分析.md @@ -0,0 +1,141 @@ +# stock-analysis 可行性分析 + +> 项目:可转债轮动策略分析系统(Go B/S 架构) +> 日期:2026-08-12 + +## 1. 项目目标 + +搭建一个基于 Go 语言的 B/S 架构量化分析系统,实现: + +- **策略制定**:用户自定义可转债轮动策略(选债因子、权重、调仓频率、风控规则) +- **历史回测**:用历史数据验证策略表现(年化收益、最大回撤、胜率、夏普比率) +- **实时交易**:通过东方财富开放平台模拟交易 API(方案4)自动下单,仅在模拟盘运行 + +## 2. 技术可行性 + +| 维度 | 方案 | 结论 | +|---|---|---| +| 后端语言 | Go 1.25.0 | ✅ 高并发 HTTP 拉取、B/S API 开发成熟 | +| 前端 | 原生 HTML/JS + ECharts(无构建链,轻量) | ✅ 满足回测曲线/持仓展示 | +| 数据库 | SQLite(modernc.org/sqlite,纯 Go 无 CGO) | ✅ 单机部署零依赖 | +| 定时任务 | Go 标准库 + goroutine 调度 | ✅ 盘中定时轮询行情 | +| 回测引擎 | 自研(事件驱动,逐日模拟) | ✅ 日频数据足够 | + +## 3. 数据源可行性(东方财富免费接口) + +### 3.1 实时全量可转债列表 + +``` +GET https://push2.eastmoney.com/api/qt/clist/get + ?pn=1&pz=100&po=1&np=1&fltt=2&invt=2&fid=f3&fs=b:MK0354 + &fields=f2,f3,f12,f14,f26,f227,f228,f230,f232,f234,f235,f236,f237,f238,f239,f240 +``` + +| 字段 | 含义 | 状态 | +|---|---|---| +| f2/f3 | 转债最新价 / 涨跌幅 | ✅ 实测确认 | +| f12/f14 | 转债代码 / 名称 | ✅ | +| f26 | 上市日期 | ✅ | +| f227 | 纯债价值 | ✅ 对照数据中心确认 | +| f228 | 正股最新价 | ✅ | +| f230 | 正股涨跌幅 | ✅ | +| f232/f234 | 正股代码 / 名称 | ✅ | +| f235 | 转股价 | ✅ | +| f236 | 转股价值 | ✅ | +| f237 | 转股溢价率(%) | ✅ | +| f238 | 纯债溢价率(%) | ✅ | +| f239/f240 | 回售/强赎触发价 | ✅ | +| f229 | ~~剩余规模~~ | ⚠️ 实测与剩余规模不符,疑似成交额,弃用 | +| 剩余规模 | 选债关键因子 | ⏳ M2 待定方案(datacenter 无此字段,需评估集思录等替代源) | + +> 实测方法:请求 `fields` 后与东财数据中心详情页(data.eastmoney.com/kzz/detail)数值逐一对照。 +> 注意:东财字段值可能是数字或 `"-"` 字符串,解析须容错;单页 `pz` 上限 100,需分页拉取。 + +### 3.2 历史K线(回测数据) + +``` +GET https://push2his.eastmoney.com/api/qt/stock/kline/get + ?secid=1.113050&klt=101&fqt=0&beg=20200101&end=20500101&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57 +``` + +- 支持日(101)/周(102)/月(103)K线,前复权/不复权 +- 返回 OHLC + 成交量 + 成交额,满足回测需求 + +### 3.3 频率限制与对策 + +| 风险 | 对策 | +|---|---| +| 无官方 SLA,接口可能变动 | 解析层隔离,字段映射集中管理 | +| 高频调用可能被封 IP | 全局限速器 500ms/请求 + 失败重试 + 指数退避 | +| 盘中数据延迟 | 以 3-5 秒轮询为主,不做毫秒级 | + +## 4. 回测可行性 + +- 数据:日频 K 线全量可下载,覆盖近 5 年 +- 交易模拟:按收盘价/次日开盘价撮合,计入佣金(万 0.5)、印花税(可转债免)、滑点(0.1%) +- 停牌处理:停牌日跳过,不产生交易 +- 策略基准:中证转债指数(可下载)对照超额收益 + +## 5. 交易执行可行性(方案4:东方财富开放平台) + +### 5.1 能力清单 + +| 能力 | 支持情况 | +|---|---| +| 模拟交易下单 | ✅ 需要申请模拟账户 + app_id/access_token/trade_key | +| 订单查询 / 撤单 | ✅ | +| 持仓 / 资金查询 | ✅ | +| 实盘交易 | ❌ 本项目不接入实盘 | + +### 5.2 限制 + +- 仅 A 股标的(可转债含在 A 股范围内,需确认模拟盘支持可转债) +- API 调用频率限制约 15 秒/次 +- 仅交易时段(9:15-15:00)可用 +- 非官方文档化,接口以实际返回为准 + +### 5.3 结论 + +模拟盘对接可行,但**先完成数据与回测模块**,交易模块作为独立阶段开发,风险隔离。 + +## 6. 系统架构 + +``` +┌─────────────────────────────────────────────┐ +│ Web 前端 (HTML + ECharts) │ +│ 策略配置 / 回测结果 / 持仓展示 / 下单操作 │ +└─────────────────────┬───────────────────────┘ + │ HTTP API +┌─────────────────────▼───────────────────────┐ +│ Go 后端 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ 策略引擎 │ │ 回测引擎 │ │ 交易模块 │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 数据层:东财接口客户端 + 限速 + 缓存 │ │ +│ └──────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 存储:SQLite(行情缓存/回测结果/配置) │ │ +│ └──────────────────────────────────────┘ │ +└──────────────┬───────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ 东方财富免费行情接口 │ ┌─────────────────┐ + │ (push2 / push2his) │ │ 开放平台模拟交易 │ + └─────────────────────┘ └─────────────────┘ +``` + +## 7. 里程碑规划 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| M1 | 可行性分析 + 项目骨架 + 数据拉取模块 | 本次 | +| M2 | 回测引擎 + 轮动策略 + 回测结果可视化 | 后续 | +| M3 | 模拟盘交易模块(方案4对接) | 后续 | +| M4 | 定时任务 + 持仓跟踪 + 风控告警 | 后续 | + +## 8. 结论 + +- 技术、数据、回测:**完全可行** +- 交易(方案4模拟盘):**有条件可行**,需先开通模拟账户 +- 建议路径:M1 → M2 优先,M3/M4 在模拟账户开通后进行 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..35b2a78 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module stock-analysis + +go 1.25.0 + +require ( + golang.org/x/crypto v0.55.0 + golang.org/x/text v0.41.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b8a00ad --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/backtest/account.go b/internal/backtest/account.go new file mode 100644 index 0000000..da3a803 --- /dev/null +++ b/internal/backtest/account.go @@ -0,0 +1,132 @@ +package backtest + +// Account 模拟账户:现金 + 持仓,负责调仓成交与逐日市值结算。 +// 可转债交易规则:面值 100 元/张,1 手 = 10 张,买入须为整手; +// T+0、无印花税,仅计单边佣金与可选滑点。 +type Account struct { + initialCash float64 + cash float64 + holdings map[string]float64 // code → 持有张数 + commission float64 + slippage float64 + curve []EquityPoint + equity float64 // 最近一次结算的净值 +} + +// NewAccount 创建初始账户。 +func NewAccount(p Params) *Account { + return &Account{ + initialCash: p.InitialCash, + cash: p.InitialCash, + holdings: make(map[string]float64), + commission: p.Commission, + slippage: p.Slippage, + equity: p.InitialCash, // 首个交易日即可建仓 + } +} + +// priceFn 返回标的指定日期(或最近)价格。封装 History.CloseOn 签名。 +type priceFn func(code, date string) (float64, bool) + +// Rebalance 按目标权重调仓:先卖后买,张数向下取整到整手(10 张)。 +// 已退市/当日停牌标的按最后已知价结算。 +func (a *Account) Rebalance(date string, targets []Target, price priceFn) { + total := a.equity + if total <= 0 { + return + } + + // 目标持仓权重表。 + want := make(map[string]float64) + for _, t := range targets { + if t.Weight > 0 { + want[t.Code] = t.Weight + } + } + + // 先卖出:超出目标权重的部分。 + for code, qty := range a.holdings { + p, ok := price(code, date) + if !ok || p <= 0 { + continue // 无价格信息,暂不处理该持仓 + } + targetQty := lots(total*want[code]/(p*10)) * 10 + if targetQty < qty { + sell := qty - targetQty + proceeds := sell * p * (1 - a.slippage) + a.cash += proceeds - proceeds*a.commission + if targetQty == 0 { + delete(a.holdings, code) + } else { + a.holdings[code] = targetQty + } + } + } + + // 再买入:未达目标权重的部分。 + for code, weight := range want { + p, ok := price(code, date) + if !ok || p <= 0 { + continue + } + targetQty := lots(total*weight/(p*10)) * 10 + cur := a.holdings[code] + if targetQty <= cur { + continue + } + buy := targetQty - cur + if avail := a.buyableLots(p); avail < buy/10 { + buy = avail * 10 + } + if buy <= 0 { + continue + } + cost := buy * p * (1 + a.slippage) + a.cash -= cost + cost*a.commission + a.holdings[code] = cur + buy + } +} + +// buyableLots 返回当前现金可买的手数(整手向下取整)。 +func (a *Account) buyableLots(price float64) float64 { + unitCost := price * 10 * (1 + a.slippage) * (1 + a.commission) + if unitCost <= 0 { + return 0 + } + return lots(a.cash / unitCost) +} + +// lots 向下取整到整数手。 +func lots(v float64) float64 { + return float64(int(v)) +} + +// MarkToMarket 按收盘价结算当日净值并追加到净值曲线。 +func (a *Account) MarkToMarket(date string, price priceFn) { + value := a.cash + for code, qty := range a.holdings { + if p, ok := price(code, date); ok { + value += qty * p + } + } + a.equity = value + init := a.initialCash + if init <= 0 { + init = 1 + } + a.curve = append(a.curve, EquityPoint{ + Date: date, + Value: value, + Return: value/init - 1, + }) +} + +// Equity 返回最近一次结算的净值。 +func (a *Account) Equity() float64 { + return a.equity +} + +// Curve 返回累计净值曲线。 +func (a *Account) Curve() []EquityPoint { + return a.curve +} diff --git a/internal/backtest/account_test.go b/internal/backtest/account_test.go new file mode 100644 index 0000000..8c33326 --- /dev/null +++ b/internal/backtest/account_test.go @@ -0,0 +1,159 @@ +package backtest + +import ( + "math" + "testing" +) + +// mockPrice 返回一个简单的价格函数,所有标的固定价格。 +func mockPrice(price float64) priceFn { + return func(code, date string) (float64, bool) { + return price, true + } +} + +// mockPriceMap 返回按 code 返回不同价格的价格函数。 +func mockPriceMap(prices map[string]float64) priceFn { + return func(code, date string) (float64, bool) { + p, ok := prices[code] + return p, ok + } +} + +func TestNewAccount(t *testing.T) { + params := DefaultParams() + params.InitialCash = 200000 + a := NewAccount(params) + if a.cash != 200000 { + t.Errorf("cash: expected 200000, got %f", a.cash) + } + if a.equity != 200000 { + t.Errorf("equity: expected 200000, got %f", a.equity) + } + if len(a.holdings) != 0 { + t.Errorf("holdings: expected empty, got %d", len(a.holdings)) + } +} + +func TestMarkToMarket_NoHoldings(t *testing.T) { + a := NewAccount(DefaultParams()) + a.MarkToMarket("2020-01-02", mockPrice(100)) + if a.equity != 100000 { + t.Errorf("equity: expected 100000, got %f", a.equity) + } + if len(a.curve) != 1 { + t.Errorf("curve: expected 1 point, got %d", len(a.curve)) + } + if a.curve[0].Return != 0 { + t.Errorf("return: expected 0, got %f", a.curve[0].Return) + } +} + +func TestMarkToMarket_WithHoldings(t *testing.T) { + a := NewAccount(DefaultParams()) + // 手动设置持仓:100 张,每张 100 元 + a.holdings["113001"] = 100 + a.MarkToMarket("2020-01-02", mockPrice(100)) + // equity = cash(100000) + 100*100 = 110000 + expected := 100000.0 + 100*100 + if math.Abs(a.equity-expected) > 0.001 { + t.Errorf("equity: expected %f, got %f", expected, a.equity) + } + // return = 110000/100000 - 1 = 0.1 + if math.Abs(a.curve[0].Return-0.1) > 0.001 { + t.Errorf("return: expected 0.1, got %f", a.curve[0].Return) + } +} + +func TestRebalance_BuyOnly(t *testing.T) { + params := Params{ + InitialCash: 100000, + Commission: 0, + Slippage: 0, + } + a := NewAccount(params) + + targets := []Target{ + {Code: "113001", Weight: 0.5}, + {Code: "113002", Weight: 0.5}, + } + + prices := map[string]float64{"113001": 100, "113002": 120} + a.Rebalance("2020-01-02", targets, mockPriceMap(prices)) + + // 总权益 100000 + // 113001: 50000 / (100*10) = 50 手 = 500 张, cost = 500*100 = 50000 + // 113002: 50000 / (120*10) = 41.67 → 41 手 = 410 张, cost = 410*120 = 49200 + // 剩余现金 = 100000 - 50000 - 49200 = 800 + if a.holdings["113001"] != 500 { + t.Errorf("113001 holdings: expected 500, got %f", a.holdings["113001"]) + } + if a.holdings["113002"] != 410 { + t.Errorf("113002 holdings: expected 410, got %f", a.holdings["113002"]) + } + if math.Abs(a.cash-800) > 0.01 { + t.Errorf("cash: expected 800, got %f", a.cash) + } +} + +func TestRebalance_NoTargets(t *testing.T) { + a := NewAccount(DefaultParams()) + a.holdings["113001"] = 100 + a.Rebalance("2020-01-02", nil, mockPrice(100)) + // 无目标 → 全部卖出 + if a.holdings["113001"] != 0 { + t.Errorf("holdings should be sold, got %f", a.holdings["113001"]) + } +} + +func TestRebalance_SellOnly(t *testing.T) { + params := Params{ + InitialCash: 100000, + Commission: 0, + Slippage: 0, + } + a := NewAccount(params) + a.holdings["113001"] = 500 + // 目标:只持有 113002,权重 1.0 + targets := []Target{{Code: "113002", Weight: 1.0}} + prices := map[string]float64{"113001": 100, "113002": 100} + a.Rebalance("2020-01-02", targets, mockPriceMap(prices)) + + // 113001 应被全部卖出 + if _, ok := a.holdings["113001"]; ok { + t.Errorf("113001 should be sold") + } + // 113002 应被买入 + if a.holdings["113002"] <= 0 { + t.Errorf("113002 should be bought") + } +} + +func TestLots(t *testing.T) { + if lots(5.7) != 5 { + t.Errorf("lots(5.7): expected 5, got %f", lots(5.7)) + } + if lots(10.0) != 10 { + t.Errorf("lots(10.0): expected 10, got %f", lots(10.0)) + } + if lots(0.3) != 0 { + t.Errorf("lots(0.3): expected 0, got %f", lots(0.3)) + } +} + +func TestEquity_InitialValue(t *testing.T) { + a := NewAccount(DefaultParams()) + if a.Equity() != 100000 { + t.Errorf("initial equity: expected 100000, got %f", a.Equity()) + } +} + +func TestCurve_Accumulation(t *testing.T) { + a := NewAccount(DefaultParams()) + a.MarkToMarket("2020-01-02", mockPrice(100)) + a.MarkToMarket("2020-01-03", mockPrice(100)) + c := a.Curve() + if len(c) != 2 { + t.Errorf("curve length: expected 2, got %d", len(c)) + } +} \ No newline at end of file diff --git a/internal/backtest/engine.go b/internal/backtest/engine.go new file mode 100644 index 0000000..a3ec491 --- /dev/null +++ b/internal/backtest/engine.go @@ -0,0 +1,186 @@ +package backtest + +import ( + "context" + "fmt" + "sort" + + "stock-analysis/internal/model" + "stock-analysis/internal/storage" +) + +// Params 回测运行参数。 +type Params struct { + InitialCash float64 // 初始资金(元) + Commission float64 // 单边佣金比例(含佣金,默认 0.00025) + Slippage float64 // 滑点比例(默认 0) + StartDate string // 回测起始日(含),空则使用数据首日 + EndDate string // 回测结束日(含),空则使用数据末日 +} + +// DefaultParams 返回推荐默认参数。 +func DefaultParams() Params { + return Params{ + InitialCash: 100000, + Commission: 0.00025, + } +} + +// DataFeed 从存储加载全量K线,构建按日期索引的行情数据。 +type DataFeed struct { + quotes map[string][]Quote // date → 当日全部转债行情 + dates []string // 升序交易日列表 + hist *History +} + +// NewDataFeed 从存储加载全部转债K线并构建回测数据源。 +func NewDataFeed(ctx context.Context, store storage.KlineStore) (*DataFeed, error) { + all, err := store.LoadAllKlines(ctx) + if err != nil { + return nil, fmt.Errorf("load klines: %w", err) + } + + quotes := make(map[string][]Quote) + for code, ks := range all { + for _, k := range ks { + quotes[k.Date] = append(quotes[k.Date], Quote{ + Code: code, + Price: k.Close, + Open: k.Open, + High: k.High, + Low: k.Low, + Volume: k.Volume, + ConvertValue: k.ConvertValue, + PremiumRate: k.PremiumRate, + }) + } + } + + dates := make([]string, 0, len(quotes)) + for d := range quotes { + dates = append(dates, d) + } + sort.Strings(dates) + + return &DataFeed{quotes: quotes, dates: dates, hist: &History{klines: all}}, nil +} + +// Dates 返回升序交易日列表。 +func (d *DataFeed) Dates() []string { + return d.dates +} + +// QuotesOn 返回指定交易日的全部转债行情。 +func (d *DataFeed) QuotesOn(date string) []Quote { + return d.quotes[date] +} + +// History 返回全量历史数据访问器。 +func (d *DataFeed) History() *History { + return d.hist +} + +// Filter 只保留指定代码的数据,用于按用户选择的转债子集回测。 +func (d *DataFeed) Filter(codes []string) { + keep := make(map[string]bool, len(codes)) + for _, c := range codes { + keep[c] = true + } + + // 过滤 quotes。 + for date, qs := range d.quotes { + var filtered []Quote + for _, q := range qs { + if keep[q.Code] { + filtered = append(filtered, q) + } + } + d.quotes[date] = filtered + } + + // 过滤 history.klines。 + filteredKlines := make(map[string][]model.Kline) + for code, ks := range d.hist.klines { + if keep[code] { + filteredKlines[code] = ks + } + } + d.hist.klines = filteredKlines +} + +// Engine 事件驱动回测引擎。 +type Engine struct { + params Params + strategy Strategy + account *Account + filter *FundamentalFilter + fundMap map[string]*model.StockFundamental +} + +// NewEngine 创建回测引擎。 +func NewEngine(params Params, strategy Strategy) *Engine { + return &Engine{params: params, strategy: strategy} +} + +// SetFundamentalFilter 设置基本面过滤器(可选)。 +// stock2bond 为正股代码到转债代码的映射;fundamentals 为正股代码到基本面数据的映射。 +func (e *Engine) SetFundamentalFilter(filter *FundamentalFilter, fundamentals map[string]*model.StockFundamental) { + e.filter = filter + e.fundMap = fundamentals +} + +// Run 执行回测,返回结果(含净值曲线与绩效指标)。 +func (e *Engine) Run(ctx context.Context, feed *DataFeed) (*Result, error) { + e.account = NewAccount(e.params) + + for _, date := range feed.dates { + if e.params.StartDate != "" && date < e.params.StartDate { + continue + } + if e.params.EndDate != "" && date > e.params.EndDate { + break + } + + universe := feed.quotes[date] + if e.filter != nil { + universe = e.filter.Apply(ctx, universe, e.fundMap) + } + targets, err := e.strategy.OnBar(ctx, date, universe, feed.hist) + if err != nil { + return nil, fmt.Errorf("strategy %s on %s: %w", e.strategy.Name(), date, err) + } + if targets != nil { + e.account.Rebalance(date, targets, feed.hist.CloseOn) + } + e.account.MarkToMarket(date, feed.hist.CloseOn) + } + + curve := e.account.Curve() + start, end := "", "" + if len(curve) > 0 { + start, end = curve[0].Date, curve[len(curve)-1].Date + } + return &Result{ + Strategy: e.strategy.Name(), + Start: start, + End: end, + Metrics: ComputeMetrics(curve), + Curve: curve, + }, nil +} + +// Result 回测输出。 +type Result struct { + Strategy string + Start string + End string + Metrics Metrics + Curve []EquityPoint +} + +// EquityPoint 净值曲线点。 +type EquityPoint struct { + Date string + Value float64 // 组合总市值 + Return float64 // 累计收益率 +} diff --git a/internal/backtest/fundamental.go b/internal/backtest/fundamental.go new file mode 100644 index 0000000..f2bfb0a --- /dev/null +++ b/internal/backtest/fundamental.go @@ -0,0 +1,83 @@ +package backtest + +import ( + "context" + + "stock-analysis/internal/model" +) + +// FundamentalFilter 正股基本面过滤器,用于排除信用风险过高的转债。 +// 过滤规则: +// - ST / *ST 正股对应的转债一律排除 +// - 资产负债率超过阈值的转债排除 +// - 净利润为负(连续亏损)的转债排除 +type FundamentalFilter struct { + MaxDebtRatio float64 // 资产负债率上限(%),默认 70 + stock2bond map[string]string + enabled bool +} + +// NewFundamentalFilter 创建基本面过滤器。 +// stock2bond 为正股代码到转债代码的映射(一只正股可能对应多只转债,取任意一只即可)。 +func NewFundamentalFilter(stock2bond map[string]string) *FundamentalFilter { + return &FundamentalFilter{ + MaxDebtRatio: 70, + stock2bond: stock2bond, + enabled: true, + } +} + +// SetEnabled 启用/禁用过滤器。 +func (f *FundamentalFilter) SetEnabled(v bool) { f.enabled = v } + +// Apply 给定当日 universe 行情和基本面数据,返回过滤后的行情列表。 +// fundamentals 的 key 为正股代码。 +func (f *FundamentalFilter) Apply(ctx context.Context, universe []Quote, fundamentals map[string]*model.StockFundamental) []Quote { + if !f.enabled || len(fundamentals) == 0 { + return universe + } + + // 构建转债代码到正股代码的映射(反向查找)。 + bond2stock := make(map[string]string, len(f.stock2bond)) + for stock, bond := range f.stock2bond { + bond2stock[bond] = stock + } + + filtered := make([]Quote, 0, len(universe)) + for _, q := range universe { + stockCode, ok := bond2stock[q.Code] + if !ok { + // 无正股映射,允许通过(不因数据缺失误杀)。 + filtered = append(filtered, q) + continue + } + fund, ok := fundamentals[stockCode] + if !ok { + // 无基本面数据,允许通过。 + filtered = append(filtered, q) + continue + } + if f.isBlocked(fund) { + continue + } + filtered = append(filtered, q) + } + return filtered +} + +// isBlocked 判断正股基本面是否触发过滤条件。 +func (f *FundamentalFilter) isBlocked(fund *model.StockFundamental) bool { + // ST / *ST 一律排除。 + if fund.Status >= 2 { + return true + } + // 资产负债率超过阈值。 + if f.MaxDebtRatio > 0 && fund.DebtRatio > f.MaxDebtRatio { + return true + } + // 净利润为负(连续亏损)。 + if fund.NetProfit < 0 { + return true + } + return false +} \ No newline at end of file diff --git a/internal/backtest/metrics.go b/internal/backtest/metrics.go new file mode 100644 index 0000000..693cb99 --- /dev/null +++ b/internal/backtest/metrics.go @@ -0,0 +1,85 @@ +package backtest + +import "math" + +// Metrics 回测绩效指标。 +type Metrics struct { + TotalReturn float64 // 累计收益率 + AnnualReturn float64 // 年化收益率 + MaxDrawdown float64 // 最大回撤(正值) + Sharpe float64 // 年化夏普比率(无风险利率为 0) + Volatility float64 // 年化波动率 + WinRatio float64 // 正收益交易日占比 + TradeDays int // 回测交易日数 +} + +const tradingDaysPerYear = 252.0 + +// ComputeMetrics 从净值曲线计算绩效指标。 +func ComputeMetrics(curve []EquityPoint) Metrics { + m := Metrics{TradeDays: len(curve)} + if len(curve) == 0 { + return m + } + m.TotalReturn = curve[len(curve)-1].Return + + // 日收益率序列。 + rets := make([]float64, 0, len(curve)-1) + for i := 1; i < len(curve); i++ { + if curve[i-1].Value > 0 { + rets = append(rets, curve[i].Value/curve[i-1].Value-1) + } + } + + // 最大回撤:遍历峰值。 + var peak, maxDD float64 + for _, p := range curve { + if p.Value > peak { + peak = p.Value + } + if dd := peak - p.Value; dd > maxDD { + maxDD = dd + } + } + if peak > 0 { + m.MaxDrawdown = maxDD / peak + } + + n := len(rets) + if n == 0 { + return m + } + + // 年化收益率。 + years := float64(n) / tradingDaysPerYear + if years > 0 && 1+m.TotalReturn > 0 { + m.AnnualReturn = math.Pow(1+m.TotalReturn, 1/years) - 1 + } + + // 波动率与夏普。 + mean := 0.0 + for _, r := range rets { + mean += r + } + mean /= float64(n) + + var variance float64 + for _, r := range rets { + variance += (r - mean) * (r - mean) + } + variance /= float64(n - 1) + m.Volatility = math.Sqrt(variance) * math.Sqrt(tradingDaysPerYear) + if m.Volatility > 0 { + m.Sharpe = mean * tradingDaysPerYear / m.Volatility + } + + // 正收益日占比。 + win := 0 + for _, r := range rets { + if r > 0 { + win++ + } + } + m.WinRatio = float64(win) / float64(n) + return m +} diff --git a/internal/backtest/metrics_test.go b/internal/backtest/metrics_test.go new file mode 100644 index 0000000..6678408 --- /dev/null +++ b/internal/backtest/metrics_test.go @@ -0,0 +1,98 @@ +package backtest + +import ( + "math" + "testing" +) + +func TestComputeMetrics_Empty(t *testing.T) { + m := ComputeMetrics(nil) + if m.TradeDays != 0 { + t.Errorf("expected 0 trade days, got %d", m.TradeDays) + } +} + +func TestComputeMetrics_SinglePoint(t *testing.T) { + curve := []EquityPoint{{Date: "2020-01-02", Value: 100000, Return: 0}} + m := ComputeMetrics(curve) + if m.TotalReturn != 0 { + t.Errorf("expected 0 total return, got %f", m.TotalReturn) + } +} + +func TestComputeMetrics_SteadyGrowth(t *testing.T) { + // 10 个交易日,每天涨 1% + curve := make([]EquityPoint, 11) + curve[0] = EquityPoint{Date: "2020-01-02", Value: 100000, Return: 0} + for i := 1; i <= 10; i++ { + val := curve[i-1].Value * 1.01 + curve[i] = EquityPoint{ + Date: "2020-01-02", + Value: val, + Return: val/100000 - 1, + } + } + m := ComputeMetrics(curve) + expectedReturn := 100000*math.Pow(1.01, 10)/100000 - 1 + if math.Abs(m.TotalReturn-expectedReturn) > 0.0001 { + t.Errorf("total return: expected %f, got %f", expectedReturn, m.TotalReturn) + } + if m.WinRatio != 1.0 { + t.Errorf("win ratio: expected 1.0, got %f", m.WinRatio) + } + if m.MaxDrawdown > 0.0001 { + t.Errorf("max drawdown: expected 0, got %f", m.MaxDrawdown) + } + if m.TradeDays != 11 { + t.Errorf("trade days: expected 11, got %d", m.TradeDays) + } +} + +func TestComputeMetrics_Drawdown(t *testing.T) { + // 先涨后跌:100000 → 110000 → 99000 + curve := []EquityPoint{ + {Date: "2020-01-02", Value: 100000, Return: 0}, + {Date: "2020-01-03", Value: 110000, Return: 0.1}, + {Date: "2020-01-06", Value: 99000, Return: -0.01}, + } + m := ComputeMetrics(curve) + if m.TotalReturn != -0.01 { + t.Errorf("total return: expected -0.01, got %f", m.TotalReturn) + } + // 峰值 110000, 谷 99000, 回撤 = (110000-99000)/110000 = 0.1 + expectedDD := 0.1 + if math.Abs(m.MaxDrawdown-expectedDD) > 0.0001 { + t.Errorf("max drawdown: expected %f, got %f", expectedDD, m.MaxDrawdown) + } +} + +func TestComputeMetrics_MixedPerformance(t *testing.T) { + // 模拟实际场景:混合涨跌 + curve := []EquityPoint{ + {Date: "2020-01-02", Value: 100000, Return: 0}, + {Date: "2020-01-03", Value: 101000, Return: 0.01}, // +1% + {Date: "2020-01-06", Value: 100500, Return: 0.005}, // -0.5% + {Date: "2020-01-07", Value: 102000, Return: 0.02}, // +1.5% + {Date: "2020-01-08", Value: 101500, Return: 0.015}, // -0.5% + {Date: "2020-01-09", Value: 103000, Return: 0.03}, // +1.5% + } + m := ComputeMetrics(curve) + if m.TotalReturn != 0.03 { + t.Errorf("total return: expected 0.03, got %f", m.TotalReturn) + } + if m.TradeDays != 6 { + t.Errorf("trade days: expected 6, got %d", m.TradeDays) + } + // 3 个正收益日 / 5 个交易日 + if math.Abs(m.WinRatio-0.6) > 0.0001 { + t.Errorf("win ratio: expected 0.6, got %f", m.WinRatio) + } + // 年化收益应 > 0 + if m.AnnualReturn <= 0 { + t.Errorf("annual return should be positive, got %f", m.AnnualReturn) + } + // 波动率应 > 0 + if m.Volatility <= 0 { + t.Errorf("volatility should be positive, got %f", m.Volatility) + } +} \ No newline at end of file diff --git a/internal/backtest/strategy.go b/internal/backtest/strategy.go new file mode 100644 index 0000000..d994cce --- /dev/null +++ b/internal/backtest/strategy.go @@ -0,0 +1,60 @@ +// Package backtest 提供可转债量化回测引擎(MVC Service 层)。 +// 采用事件驱动模式:按交易日推进,策略在任意交易日返回目标持仓, +// 引擎以当日收盘价成交并逐日记录组合净值。 +package backtest + +import ( + "context" + "sort" + + "stock-analysis/internal/model" +) + +// Strategy 定义可插拔的回测策略接口。 +// 用户实现该接口即可自定义轮动/网格等策略,无需关心成交与持仓细节。 +type Strategy interface { + // Name 返回策略名称(用于结果标识)。 + Name() string + // OnBar 每个交易日调用。返回 nil 表示当日不调仓; + // 返回目标持仓时,引擎按各标的权重以当日收盘价调仓。 + OnBar(ctx context.Context, date string, universe []Quote, hist *History) ([]Target, error) +} + +// Quote 某转债在某一交易日的基本行情快照。 +// 溢价率数据来自基本面入库(cmd/fundamentals 步骤3),未计算时为 0。 +type Quote struct { + Code string + Price float64 // 收盘价 + Open float64 + High float64 + Low float64 + Volume int64 + ConvertValue float64 // 转股价值(元) + PremiumRate float64 // 转股溢价率(%) +} + +// Target 目标持仓:转债代码 → 组合权重(0~1,各权重之和应为 1)。 +type Target struct { + Code string + Weight float64 +} + +// History 提供全量历史K线查询,策略可访问任意代码任意日期的历史行情。 +type History struct { + klines map[string][]model.Kline // code → 按日期升序的K线 +} + +// CloseOn 返回 code 在 date 当日(或之前最近交易日)的收盘价。 +// 用于处理调仓时个别持仓当日停牌/退市等无报价的情况。 +func (h *History) CloseOn(code, date string) (float64, bool) { + ks, ok := h.klines[code] + if !ok { + return 0, false + } + // 二分查找最近一根 <= date 的K线。 + i := sort.Search(len(ks), func(i int) bool { return ks[i].Date > date }) + if i == 0 { + return 0, false + } + return ks[i-1].Close, true +} diff --git a/internal/controller/api.go b/internal/controller/api.go new file mode 100644 index 0000000..98b9ec1 --- /dev/null +++ b/internal/controller/api.go @@ -0,0 +1,339 @@ +// Package controller 定义 MVC 架构中的 Controller 层, +// 负责接收 HTTP 请求、调用 Service 层业务逻辑、返回响应。 +package controller + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "stock-analysis/internal/backtest" + "stock-analysis/internal/service" + "stock-analysis/internal/storage/sqlite" + "stock-analysis/internal/strategy" +) + +// MarketController 提供行情与回测相关的 HTTP 接口。 +type MarketController struct { + svc *service.MarketService + dbPath string +} + +// NewMarketController 创建行情控制器。 +func NewMarketController(svc *service.MarketService, dbPath string) *MarketController { + return &MarketController{svc: svc, dbPath: dbPath} +} + +// ListBonds 处理 GET /api/bonds,返回全量可转债实时行情。 +func (c *MarketController) ListBonds(w http.ResponseWriter, r *http.Request) { + bonds, err := c.svc.ListConvertibles(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, bonds) +} + +// GetKline 处理 GET /api/bonds/{code}/kline,返回指定可转债日K线。 +func (c *MarketController) GetKline(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + klines, err := c.svc.GetDailyKline(r.Context(), code, "20200101") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, klines) +} + +// BacktestRequest 回测请求参数。 +type BacktestRequest struct { + Strategy string `json:"strategy"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + TopN int `json:"topN"` + Rebalance int `json:"rebalance"` + MinPrice float64 `json:"minPrice"` + MaxPrice float64 `json:"maxPrice"` + MaxPremium float64 `json:"maxPremium"` + Cash float64 `json:"cash"` + Codes []string `json:"codes"` // 指定参与回测的转债代码,空表示全部 + Fundamental bool `json:"fundamental"` // 启用正股基本面过滤器 +} + +// RunBacktest 处理 POST /api/backtest,执行回测并返回结果。 +func (c *MarketController) RunBacktest(w http.ResponseWriter, r *http.Request) { + var req BacktestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + // 默认值填充。 + if req.Strategy == "" { + req.Strategy = "lowprice" + } + if req.TopN <= 0 { + req.TopN = 10 + } + if req.Rebalance <= 0 { + req.Rebalance = 21 + } + if req.Cash <= 0 { + req.Cash = 100000 + } + if req.MaxPrice <= 0 { + req.MaxPrice = 130 + } + if req.MinPrice <= 0 { + req.MinPrice = 80 + } + if req.MaxPremium <= 0 { + req.MaxPremium = 30 + } + + ctx := r.Context() + + store, err := sqlite.Open(c.dbPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer store.Close() + + feed, err := backtest.NewDataFeed(ctx, store) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + // 如果指定了 codes,过滤数据源。 + if len(req.Codes) > 0 { + feed.Filter(req.Codes) + } + + params := backtest.DefaultParams() + params.InitialCash = req.Cash + params.StartDate = req.StartDate + params.EndDate = req.EndDate + + var strat backtest.Strategy + switch req.Strategy { + case "lowprice": + strat = strategy.NewLowPrice(strategy.LowPriceParams{ + TopN: req.TopN, + Rebalance: req.Rebalance, + MinPrice: req.MinPrice, + MaxPrice: req.MaxPrice, + }) + case "doublelow": + strat = strategy.NewDoubleLow(strategy.DoubleLowParams{ + TopN: req.TopN, + Rebalance: req.Rebalance, + MinPrice: req.MinPrice, + MaxPrice: req.MaxPrice, + MaxPremium: req.MaxPremium, + }) + case "premium": + strat = strategy.NewPremium(strategy.PremiumParams{ + TopN: req.TopN, + Rebalance: req.Rebalance, + MinPrice: req.MinPrice, + MaxPrice: req.MaxPrice, + }) + default: + writeError(w, http.StatusBadRequest, err) + return + } + + engine := backtest.NewEngine(params, strat) + + if req.Fundamental { + fundamentals, err := store.LoadAllFundamentals(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("加载基本面数据: %w", err)) + return + } + stock2bond, err := store.GetStockBondMap(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("获取正股映射: %w", err)) + return + } + filter := backtest.NewFundamentalFilter(stock2bond) + engine.SetFundamentalFilter(filter, fundamentals) + log.Printf("基本面过滤器已启用:%d 只正股数据", len(fundamentals)) + } + + res, err := engine.Run(ctx, feed) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + log.Printf("backtest done: %s, start=%s, end=%s, totalReturn=%.2f%%, curvePoints=%d, tradeDays=%d", + res.Strategy, res.Start, res.End, res.Metrics.TotalReturn*100, len(res.Curve), res.Metrics.TradeDays) + + bench := computeBenchmarkJSON(feed, req.StartDate, req.EndDate) + + writeJSON(w, http.StatusOK, map[string]any{ + "strategy": res.Strategy, + "start": res.Start, + "end": res.End, + "metrics": res.Metrics, + "curve": res.Curve, + "benchMetrics": bench.Metrics, + "benchCurve": bench.Curve, + }) +} + +// benchmarkResult 基准回测结果。 +type benchmarkResult struct { + Metrics backtest.Metrics + Curve []backtest.EquityPoint +} + +// computeBenchmarkJSON 在 Web API 中计算基准指数。 +func computeBenchmarkJSON(feed *backtest.DataFeed, from, to string) benchmarkResult { + var ( + idx float64 = 1 + prev map[string]float64 + curve []backtest.EquityPoint + hasPrev bool + ) + for _, date := range feed.Dates() { + if from != "" && date < from { + continue + } + if to != "" && date > to { + break + } + cur := make(map[string]float64) + for _, q := range feed.QuotesOn(date) { + cur[q.Code] = q.Price + } + if hasPrev { + var sum, n float64 + for code, p := range cur { + if pp, ok := prev[code]; ok && pp > 0 { + sum += p/pp - 1 + n++ + } + } + if n > 0 { + idx *= 1 + sum/n + } + } + curve = append(curve, backtest.EquityPoint{Date: date, Value: idx, Return: idx - 1}) + prev = cur + hasPrev = true + } + return benchmarkResult{ + Metrics: backtest.ComputeMetrics(curve), + Curve: curve, + } +} + +// DataStatus 数据状态信息。 +type DataStatus struct { + DBPath string `json:"dbPath"` + BondCount int `json:"bondCount"` + KlineCount int `json:"klineCount"` + DateRange string `json:"dateRange"` +} + +// DBBond 数据库中转债简要信息。 +type DBBond struct { + Code string `json:"Code"` + Name string `json:"Name"` + KlineCount int `json:"KlineCount"` +} + +// ListDBBonds 处理 GET /api/db/bonds,返回数据库中已有的转债列表。 +func (c *MarketController) ListDBBonds(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + store, err := sqlite.Open(c.dbPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer store.Close() + + codes, err := store.ListCodes(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + // 加载转债名称映射。 + allBonds, _ := store.ListBonds(ctx) + nameMap := make(map[string]string, len(allBonds)) + for _, b := range allBonds { + nameMap[b.Code] = b.Name + } + + bonds := make([]DBBond, 0, len(codes)) + for _, code := range codes { + n, _ := store.CountKlines(ctx, code) + bonds = append(bonds, DBBond{Code: code, Name: nameMap[code], KlineCount: n}) + } + writeJSON(w, http.StatusOK, bonds) +} + +// GetDataStatus 处理 GET /api/status,返回数据库状态信息。 +func (c *MarketController) GetDataStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + status := DataStatus{DBPath: c.dbPath} + + store, err := sqlite.Open(c.dbPath) + if err != nil { + writeJSON(w, http.StatusOK, status) // 返回部分信息 + return + } + defer store.Close() + + codes, err := store.ListCodes(ctx) + if err == nil { + status.BondCount = len(codes) + } + + all, err := store.LoadAllKlines(ctx) + if err == nil { + for _, ks := range all { + status.KlineCount += len(ks) + } + } + + // 取日期范围。 + if len(all) > 0 { + var minDate, maxDate string + for _, ks := range all { + if len(ks) > 0 { + if minDate == "" || ks[0].Date < minDate { + minDate = ks[0].Date + } + last := ks[len(ks)-1].Date + if maxDate == "" || last > maxDate { + maxDate = last + } + } + } + status.DateRange = minDate + " ~ " + maxDate + } + + writeJSON(w, http.StatusOK, status) +} + +// writeJSON 写入 JSON 响应。 +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("write json: %v", err) + } +} + +// writeError 写入 JSON 错误响应。 +func writeError(w http.ResponseWriter, status int, err error) { + log.Printf("http %d: %v", status, err) + writeJSON(w, status, map[string]string{"error": err.Error()}) +} diff --git a/internal/controller/auth.go b/internal/controller/auth.go new file mode 100644 index 0000000..a7244a2 --- /dev/null +++ b/internal/controller/auth.go @@ -0,0 +1,168 @@ +// Package controller 定义 MVC 架构中的 Controller 层。 +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "stock-analysis/internal/model" + "stock-analysis/internal/service" +) + +// AuthController 提供用户认证相关的 HTTP 接口。 +type AuthController struct { + svc *service.AuthService +} + +// NewAuthController 创建认证控制器。 +func NewAuthController(svc *service.AuthService) *AuthController { + return &AuthController{svc: svc} +} + +// CurrentUser 返回当前请求对应的登录用户,未登录返回错误。 +func (c *AuthController) CurrentUser(r *http.Request) (*model.User, error) { + token := extractToken(r) + if token == "" { + return nil, fmt.Errorf("未登录") + } + return c.svc.ValidateToken(r.Context(), token) +} + +// sendCodeRequest 发送验证码请求。 +type sendCodeRequest struct { + Email string `json:"email"` + Purpose string `json:"purpose"` // register 或 login +} + +// SendCode 处理 POST /api/auth/send-code,发送邮箱验证码。 +func (c *AuthController) SendCode(w http.ResponseWriter, r *http.Request) { + var req sendCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if req.Purpose == "" { + req.Purpose = "register" + } + if err := c.svc.SendVerifyCode(r.Context(), req.Email, req.Purpose); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "验证码已发送"}) +} + +// registerRequest 注册请求。 +type registerRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Code string `json:"code"` + Password string `json:"password"` +} + +// Register 处理 POST /api/auth/register,邮箱+验证码注册。 +func (c *AuthController) Register(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + user, err := c.svc.Register(r.Context(), req.Username, req.Email, req.Code, req.Password) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + }) +} + +// loginRequest 登录请求。 +type loginRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Code string `json:"code"` + Password string `json:"password"` +} + +// Login 处理 POST /api/auth/login。 +// 支持两种登录方式: +// - 用户名+密码:{ username, password } +// - 邮箱+验证码:{ email, code } +func (c *AuthController) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + var user *model.User + var token string + var err error + + if req.Username != "" { + // 用户名+密码登录 + user, token, err = c.svc.LoginByUsername(r.Context(), req.Username, req.Password) + } else if req.Code != "" { + // 邮箱+验证码登录 + user, token, err = c.svc.Login(r.Context(), req.Email, req.Code) + } else { + writeError(w, http.StatusBadRequest, fmt.Errorf("请输入用户名密码或邮箱验证码")) + return + } + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "token": token, + }) +} + +// Me 处理 GET /api/auth/me,返回当前登录用户信息。 +func (c *AuthController) Me(w http.ResponseWriter, r *http.Request) { + token := extractToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, fmt.Errorf("未登录")) + return + } + u, err := c.svc.ValidateToken(r.Context(), token) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": u.ID, + "username": u.Username, + "email": u.Email, + }) +} + +// Logout 处理 POST /api/auth/logout,注销当前会话。 +func (c *AuthController) Logout(w http.ResponseWriter, r *http.Request) { + token := extractToken(r) + if token != "" { + c.svc.Logout(token) + } + writeJSON(w, http.StatusOK, map[string]string{"message": "已退出"}) +} + +// extractToken 从 Cookie 或 Authorization 头中提取 token。 +func extractToken(r *http.Request) string { + if c, err := r.Cookie("auth_token"); err == nil && c.Value != "" { + return c.Value + } + if h := r.Header.Get("Authorization"); h != "" { + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + } + return "" +} \ No newline at end of file diff --git a/internal/datasource/datasource.go b/internal/datasource/datasource.go new file mode 100644 index 0000000..a6489bb --- /dev/null +++ b/internal/datasource/datasource.go @@ -0,0 +1,17 @@ +// Package datasource 定义市场数据访问接口(MVC Model 层), +// 具体实现位于子包(如 eastmoney),便于替换数据源或添加缓存。 +package datasource + +import ( + "context" + + "stock-analysis/internal/model" +) + +// MarketDataSource 行情数据源接口。 +type MarketDataSource interface { + // FetchConvertibles 返回全量可转债实时行情。 + FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) + // FetchKline 返回指定标的的历史K线,按时间升序排列。 + FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) +} diff --git a/internal/datasource/eastmoney/client.go b/internal/datasource/eastmoney/client.go new file mode 100644 index 0000000..bdc414c --- /dev/null +++ b/internal/datasource/eastmoney/client.go @@ -0,0 +1,114 @@ +// Package eastmoney 实现 datasource.MarketDataSource 接口。 +// 数据来源:push2.eastmoney.com(实时行情)、push2his.eastmoney.com(历史K线)。 +// 注意:该接口为非官方免费接口,无 SLA 保障,使用需限速并做好容错。 +package eastmoney + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + // requestInterval 请求间隔,避免高频调用被封 IP(东财对短时高频请求限流较严格)。 + requestInterval = 2 * time.Second + // maxRetry 每个域名上的失败重试次数。 + maxRetry = 2 +) + +// realTimeHosts 实时行情域名列表,主域名被限流时轮询备用(东财数字前缀节点)。 +var realTimeHosts = []string{ + "https://push2.eastmoney.com", + "https://16.push2.eastmoney.com", + "https://20.push2.eastmoney.com", + "https://30.push2.eastmoney.com", + "https://41.push2.eastmoney.com", + "https://54.push2.eastmoney.com", + "https://69.push2.eastmoney.com", + "https://82.push2.eastmoney.com", +} + +// historyHosts 历史K线域名列表(主域名被限流时轮询备用节点)。 +var historyHosts = []string{ + "https://push2his.eastmoney.com", + "https://16.push2his.eastmoney.com", + "https://20.push2his.eastmoney.com", + "https://30.push2his.eastmoney.com", + "https://41.push2his.eastmoney.com", + "https://54.push2his.eastmoney.com", + "https://69.push2his.eastmoney.com", + "https://82.push2his.eastmoney.com", +} + +// Client 是东方财富行情接口客户端,实现 datasource.MarketDataSource。 +type Client struct { + http *http.Client + lastReq time.Time +} + +// NewClient 创建东财行情客户端。 +func NewClient() *Client { + return &Client{ + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// getJSON 带限速与多域名轮询重试的 GET 请求,解析 JSON 到 out。 +// hostIdx 标识域名列表(0 实时 / 1 历史),path 为接口路径(如 /api/qt/clist/get)。 +func (c *Client) getJSON(ctx context.Context, hosts []string, path string, params url.Values, out any) error { + var lastErr error + for _, base := range hosts { + reqURL := base + path + "?" + params.Encode() + for attempt := 0; attempt < maxRetry; attempt++ { + if attempt > 0 { + // 退避:3s、6s + time.Sleep(time.Duration(3*(attempt+1)) * time.Second) + } + if err := c.fetch(ctx, reqURL, out); err != nil { + lastErr = err + continue + } + return nil + } + } + return fmt.Errorf("eastmoney request failed across all hosts: %w", lastErr) +} + +// fetch 执行 HTTP GET 并解析 JSON。 +func (c *Client) fetch(ctx context.Context, reqURL string, out any) error { + c.throttle() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return err + } + // 模拟浏览器 UA,降低被风控概率。 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + req.Header.Set("Referer", "https://quote.eastmoney.com/") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// throttle 保证请求间隔不小于 requestInterval。 +func (c *Client) throttle() { + elapsed := time.Since(c.lastReq) + if wait := requestInterval - elapsed; wait > 0 { + time.Sleep(wait) + } + c.lastReq = time.Now() +} diff --git a/internal/datasource/eastmoney/convertible.go b/internal/datasource/eastmoney/convertible.go new file mode 100644 index 0000000..2a850a0 --- /dev/null +++ b/internal/datasource/eastmoney/convertible.go @@ -0,0 +1,139 @@ +package eastmoney + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" + + "stock-analysis/internal/model" +) + +// clistResp 是 clist 接口响应结构。 +type clistResp struct { + Data struct { + Total int `json:"total"` + Diff json.RawMessage `json:"diff"` + } `json:"data"` +} + +// rawBond 是 clist 接口返回的可转债原始字段。 +// 东财的值可能是数字或 "-" 字符串,数字字段统一用 json.RawMessage 容错。 +type rawBond struct { + F2 json.RawMessage `json:"f2"` // 转债最新价 + F3 json.RawMessage `json:"f3"` // 转债涨跌幅 + F12 string `json:"f12"` // 转债代码 + F14 string `json:"f14"` // 转债名称 + F26 json.RawMessage `json:"f26"` // 上市日期 + F227 json.RawMessage `json:"f227"` // 纯债价值 + F228 json.RawMessage `json:"f228"` // 正股最新价 + F230 json.RawMessage `json:"f230"` // 正股涨跌幅 + F232 string `json:"f232"` // 正股代码 + F234 string `json:"f234"` // 正股名称 + F235 json.RawMessage `json:"f235"` // 转股价 + F236 json.RawMessage `json:"f236"` // 转股价值 + F237 json.RawMessage `json:"f237"` // 转股溢价率 + F238 json.RawMessage `json:"f238"` // 纯债溢价率 + F239 json.RawMessage `json:"f239"` // 回售触发价 + F240 json.RawMessage `json:"f240"` // 强赎触发价 +} + +// FetchConvertibles 分页拉取全量可转债实时行情。 +func (c *Client) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) { + const pageSize = 100 // 东财 clist 单页上限 + bonds := make([]model.ConvertibleBond, 0, 400) + + for page := 1; ; page++ { + params := url.Values{} + params.Set("pn", strconv.Itoa(page)) + params.Set("pz", strconv.Itoa(pageSize)) + params.Set("po", "1") + params.Set("np", "1") + params.Set("fltt", "2") + params.Set("invt", "2") + params.Set("fid", "f3") + params.Set("fs", "b:MK0354") // 可转债市场 + params.Set("fields", "f2,f3,f12,f14,f26,f227,f228,f230,f232,f234,f235,f236,f237,f238,f239,f240") + + var resp clistResp + if err := c.getJSON(ctx, realTimeHosts, "/api/qt/clist/get", params, &resp); err != nil { + return nil, err + } + if len(resp.Data.Diff) == 0 { + break + } + + // diff 可能是数组,也可能(仅一条时)是对象。 + var items []json.RawMessage + if isJSONArray(resp.Data.Diff) { + if err := json.Unmarshal(resp.Data.Diff, &items); err != nil { + return nil, err + } + } else { + items = []json.RawMessage{resp.Data.Diff} + } + + for _, raw := range items { + var b rawBond + if err := json.Unmarshal(raw, &b); err != nil { + continue + } + if b.F12 == "" { + continue + } + bonds = append(bonds, model.ConvertibleBond{ + Code: b.F12, + Name: b.F14, + Price: parseFloat(b.F2), + ChangePct: parseFloat(b.F3), + ListDate: parseDate(b.F26), + StockCode: b.F232, + StockName: b.F234, + StockPrice: parseFloat(b.F228), + StockChangePct: parseFloat(b.F230), + ConvertPrice: parseFloat(b.F235), + ConvertValue: parseFloat(b.F236), + PremiumRate: parseFloat(b.F237), + PureBondVal: parseFloat(b.F227), + PureBondPremium: parseFloat(b.F238), + ResaleTrigPrice: parseFloat(b.F239), + RedeemTrigPrice: parseFloat(b.F240), + }) + } + + // 拉满即停止。 + if len(bonds) >= resp.Data.Total { + break + } + } + return bonds, nil +} + +// isJSONArray 判断原始 JSON 是否为数组。 +func isJSONArray(raw json.RawMessage) bool { + s := strings.TrimSpace(string(raw)) + return strings.HasPrefix(s, "[") +} + +// parseFloat 容错解析东财字段:数字、"-"、空串、null 均处理。 +func parseFloat(raw json.RawMessage) float64 { + if len(raw) == 0 { + return 0 + } + s := strings.Trim(string(raw), `"`) + if s == "" || s == "-" || s == "null" || s == "None" { + return 0 + } + v, _ := strconv.ParseFloat(s, 64) + return v +} + +// parseDate 解析东财日期字段(如 20260812 -> 2006-01-02 格式)。 +func parseDate(raw json.RawMessage) string { + s := strings.Trim(string(raw), `"`) + if len(s) != 8 { + return s + } + return s[:4] + "-" + s[4:6] + "-" + s[6:8] +} diff --git a/internal/datasource/eastmoney/datacenter.go b/internal/datasource/eastmoney/datacenter.go new file mode 100644 index 0000000..9786286 --- /dev/null +++ b/internal/datasource/eastmoney/datacenter.go @@ -0,0 +1,140 @@ +// Package eastmoney 东财数据源:datacenter(列表/基础信息)与 push2(实时/历史行情)。 +package eastmoney + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "strconv" + "time" + + "stock-analysis/internal/model" +) + +// datacenterHost 东财数据中心接口域名(与 push2 分属不同集群,限流相对宽松)。 +const datacenterHost = "https://datacenter-web.eastmoney.com" + +// datacenterResp 是 datacenter 列表接口响应结构。 +type datacenterResp struct { + Result struct { + Data []struct { + SecurityCode string `json:"SECURITY_CODE"` // 转债代码 + SecurityName string `json:"SECURITY_NAME_ABBR"` // 转债简称 + BondStartDate string `json:"BOND_START_DATE"` // 发行日期 + ListingDate string `json:"LISTING_DATE"` // 上市日期(部分记录缺失) + DelistDate string `json:"DELIST_DATE"` // 退市日期(未退市为空) + ConvertStockCode string `json:"CONVERT_STOCK_CODE"` // 正股代码 + InitialTransferPice float64 `json:"INITIAL_TRANSFER_PRICE"` // 初始转股价 + } `json:"data"` + } `json:"result"` + Success bool `json:"success"` +} + +// FetchAllBondCodes 从 datacenter 分页拉取全量转债代码列表(含已退市,约 1000 只)。 +// 相比 push2 clist 接口不受实时行情集群限流影响,适合批量抓取场景。 +// 返回的 ConvertibleBond 含正股代码/初始转股价/上市退市日期。 +func (c *Client) FetchAllBondCodes(ctx context.Context) ([]model.ConvertibleBond, error) { + const pageSize = 100 + var bonds []model.ConvertibleBond + + for page := 1; ; page++ { + params := url.Values{} + params.Set("reportName", "RPT_BOND_CB_LIST") + params.Set("columns", "SECURITY_CODE,SECURITY_NAME_ABBR,BOND_START_DATE,LISTING_DATE,DELIST_DATE,CONVERT_STOCK_CODE,INITIAL_TRANSFER_PRICE") + params.Set("pageNumber", strconv.Itoa(page)) + params.Set("pageSize", strconv.Itoa(pageSize)) + + var resp datacenterResp + if err := c.getJSON(ctx, []string{datacenterHost}, "/api/data/v1/get", params, &resp); err != nil { + return nil, err + } + if len(resp.Result.Data) == 0 { + break + } + for _, d := range resp.Result.Data { + if d.SecurityCode == "" { + continue + } + listDate := d.ListingDate + if len(listDate) < 10 { + listDate = d.BondStartDate // 兜底:部分老债缺上市日期 + } + delistDate := "" + if len(d.DelistDate) >= 10 { + delistDate = d.DelistDate[:10] + } + bonds = append(bonds, model.ConvertibleBond{ + Code: d.SecurityCode, + Name: d.SecurityName, + StockCode: d.ConvertStockCode, + InitialConvertPrice: d.InitialTransferPice, + ListDate: listDate[:10], + DelistDate: delistDate, + }) + } + if len(resp.Result.Data) < pageSize { + break // 最后一页 + } + } + return bonds, nil +} + +// FetchLatestBonds 获取最近上市(已上市且有行情数据)的 n 只可转债。 +// 走 push2 clist 接口按上市日期(f26)倒序,过滤上市日期缺失及尚未上市(未来日期)的记录。 +func (c *Client) FetchLatestBonds(ctx context.Context, n int) ([]model.ConvertibleBond, error) { + const fetchSize = 50 // 放宽单页数量,确保过滤后仍有足够样本 + params := url.Values{} + params.Set("pn", "1") + params.Set("pz", strconv.Itoa(fetchSize)) + params.Set("po", "1") + params.Set("np", "1") + params.Set("fltt", "2") + params.Set("invt", "2") + params.Set("fid", "f26") // 按上市日期排序 + params.Set("fs", "b:MK0354") + params.Set("fields", "f12,f14,f26") + + var resp clistResp + if err := c.getJSON(ctx, realTimeHosts, "/api/qt/clist/get", params, &resp); err != nil { + return nil, err + } + if len(resp.Data.Diff) == 0 { + return nil, errors.New("eastmoney: empty clist") + } + + var items []json.RawMessage + if isJSONArray(resp.Data.Diff) { + if err := json.Unmarshal(resp.Data.Diff, &items); err != nil { + return nil, err + } + } else { + items = []json.RawMessage{resp.Data.Diff} + } + + today := time.Now().Format("2006-01-02") + bonds := make([]model.ConvertibleBond, 0, n) + for _, raw := range items { + var b rawBond + if err := json.Unmarshal(raw, &b); err != nil { + continue + } + if b.F12 == "" { + continue + } + listDate := parseDate(b.F26) + // 跳过上市日期缺失或尚未上市(日期在未来)的转债。 + if listDate == "" || listDate > today { + continue + } + bonds = append(bonds, model.ConvertibleBond{ + Code: b.F12, + Name: b.F14, + ListDate: listDate, + }) + if len(bonds) >= n { + break + } + } + return bonds, nil +} diff --git a/internal/datasource/eastmoney/fundamental.go b/internal/datasource/eastmoney/fundamental.go new file mode 100644 index 0000000..b1942a3 --- /dev/null +++ b/internal/datasource/eastmoney/fundamental.go @@ -0,0 +1,116 @@ +package eastmoney + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "stock-analysis/internal/model" +) + +// datacenterFundamentalResp 是 datacenter 财务数据接口响应。 +type datacenterFundamentalResp struct { + Result struct { + Pages int `json:"pages"` + Data []datacenterFundamentalItem `json:"data"` + } `json:"result"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// datacenterFundamentalItem 是单条财务数据记录。 +type datacenterFundamentalItem struct { + SecurityCode string `json:"SECURITY_CODE"` + SecurityName string `json:"SECURITY_NAME_ABBR"` + ROE *float64 `json:"ROEJQ"` // 加权净资产收益率(%) + DebtRatio *float64 `json:"ZCFZL"` // 资产负债率(%) + NetProfit *float64 `json:"PARENTNETPROFIT"` // 归属净利润(元) + IsBZ string `json:"IS_BZ"` // ST状态:0=正常, 1=ST, 2=*ST + ReportDate string `json:"REPORT_DATE"` +} + +// FetchStockFundamentals 批量拉取指定正股的最新年度基本面数据。 +// 使用 datacenter API(非 push2),按 REPORT_DATE 倒序取每只股票的最新一条年报。 +func (c *Client) FetchStockFundamentals(ctx context.Context, codes []string) ([]model.StockFundamental, error) { + if len(codes) == 0 { + return nil, nil + } + + codeSet := make(map[string]bool, len(codes)) + for _, code := range codes { + codeSet[code] = true + } + + const pageSize = 5000 + seen := make(map[string]bool, len(codes)) + var funds []model.StockFundamental + + // 按 REPORT_DATE 倒序拉取,每只股票取第一条(最新年报)。 + for page := 1; page <= 3; page++ { + params := url.Values{} + params.Set("reportName", "RPT_F10_FINANCE_MAINFINADATA") + params.Set("columns", "SECURITY_CODE,SECURITY_NAME_ABBR,ROEJQ,ZCFZL,PARENTNETPROFIT,IS_BZ,REPORT_DATE") + params.Set("filter", `(REPORT_TYPE="年报")`) + params.Set("pageNumber", strconv.Itoa(page)) + params.Set("pageSize", strconv.Itoa(pageSize)) + params.Set("sortColumns", "REPORT_DATE") + params.Set("sortTypes", "-1") + + var resp datacenterFundamentalResp + if err := c.getJSON(ctx, []string{datacenterHost}, "/api/data/v1/get", params, &resp); err != nil { + return nil, fmt.Errorf("datacenter page %d: %w", page, err) + } + if !resp.Success { + return nil, fmt.Errorf("datacenter API error: %s", resp.Message) + } + if len(resp.Result.Data) == 0 { + break + } + + for _, d := range resp.Result.Data { + code := d.SecurityCode + if code == "" || seen[code] || !codeSet[code] { + continue + } + seen[code] = true + + roe := 0.0 + if d.ROE != nil { + roe = *d.ROE + } + debtRatio := 0.0 + if d.DebtRatio != nil { + debtRatio = *d.DebtRatio + } + netProfit := 0.0 + if d.NetProfit != nil { + netProfit = *d.NetProfit + } + status := 1 + if d.IsBZ == "1" || d.IsBZ == "2" { + status = 2 + } + + updateDate := strings.Split(d.ReportDate, " ")[0] + + funds = append(funds, model.StockFundamental{ + Code: code, + Name: d.SecurityName, + ROE: roe, + DebtRatio: debtRatio, + NetProfit: netProfit, + Status: status, + UpdateDate: updateDate, + }) + } + + // 已找到全部目标股票,停止翻页。 + if len(seen) >= len(codes) { + break + } + } + + return funds, nil +} \ No newline at end of file diff --git a/internal/datasource/eastmoney/kline.go b/internal/datasource/eastmoney/kline.go new file mode 100644 index 0000000..c5b4b61 --- /dev/null +++ b/internal/datasource/eastmoney/kline.go @@ -0,0 +1,99 @@ +package eastmoney + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "stock-analysis/internal/model" +) + +// Kline 周期常量(东财 klt 参数)。 +const ( + KltDay = "101" // 日K + KltWeek = "102" // 周K + KltMonth = "103" // 月K +) + +// klineResp 是 K线接口响应结构。 +type klineResp struct { + Data *struct { + Code string `json:"code"` + Name string `json:"name"` + Klines []string `json:"klines"` + } `json:"data"` +} + +// FetchKline 拉取指定标的历史K线。 +// code 为标的代码(如 113667),klt 为周期,beg/end 形如 20200101。 +// 返回按时间升序排列的K线。 +func (c *Client) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) { + secid := secidOf(code) + params := url.Values{} + params.Set("secid", secid) + params.Set("klt", klt) + params.Set("fqt", "0") // 不复权(转债回测用不复权价) + params.Set("beg", beg) + params.Set("end", end) + params.Set("fields1", "f1,f2,f3,f4,f5,f6") + params.Set("fields2", "f51,f52,f53,f54,f55,f56,f57") + + var resp klineResp + if err := c.getJSON(ctx, historyHosts, "/api/qt/stock/kline/get", params, &resp); err != nil { + return nil, err + } + if resp.Data == nil || len(resp.Data.Klines) == 0 { + return nil, fmt.Errorf("kline empty: secid=%s", secid) + } + + klines := make([]model.Kline, 0, len(resp.Data.Klines)) + for _, line := range resp.Data.Klines { + // 格式:日期,开,收,高,低,成交量,成交额 + parts := strings.Split(line, ",") + if len(parts) < 7 { + continue + } + k, err := parseKline(parts) + if err != nil { + continue + } + klines = append(klines, k) + } + return klines, nil +} + +// secidOf 根据代码生成东财 secid。 +// 沪市(11/118 开头)前缀 1,深市(12 开头)前缀 0。 +func secidOf(code string) string { + if strings.HasPrefix(code, "12") { + return "0." + code + } + return "1." + code +} + +// parseKline 解析一行K线数据。 +func parseKline(parts []string) (model.Kline, error) { + open, err1 := strconv.ParseFloat(parts[1], 64) + close_, err2 := strconv.ParseFloat(parts[2], 64) + high, err3 := strconv.ParseFloat(parts[3], 64) + low, err4 := strconv.ParseFloat(parts[4], 64) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil { + return model.Kline{}, fmt.Errorf("bad kline numbers") + } + volume, err := strconv.ParseInt(parts[5], 10, 64) + if err != nil { + volume = 0 + } + amount, _ := strconv.ParseFloat(parts[6], 64) + return model.Kline{ + Date: parts[0], + Open: open, + Close: close_, + High: high, + Low: low, + Volume: volume, + Amount: amount, + }, nil +} diff --git a/internal/datasource/fallback/fallback.go b/internal/datasource/fallback/fallback.go new file mode 100644 index 0000000..18419ee --- /dev/null +++ b/internal/datasource/fallback/fallback.go @@ -0,0 +1,166 @@ +// Package fallback 实现 datasource.MarketDataSource 接口,提供数据源主备切换能力。 +// +// 主数据源(如东财)用于实时行情和历史K线;当主数据源历史K线请求失败时, +// 自动切换到备用数据源(如腾讯),确保数据入库和回测的可用性。 +// 实时行情(FetchConvertibles)支持主备切换:主数据源失败时, +// 走"东财 datacenter 列表 + 腾讯实时报价"的组合策略补充实时价格。 +// +// 为提高数据源故障时的整体效率,fallback 内置熔断机制:主数据源K线连续 +// 失败达到阈值后,进入熔断窗口,期间直接使用备用数据源,不再尝试主数据源; +// 熔断窗口结束后自动放行一次探测,成功则解除熔断,恢复主备切换。 +package fallback + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "stock-analysis/internal/datasource" + "stock-analysis/internal/datasource/eastmoney" + "stock-analysis/internal/datasource/tencent" + "stock-analysis/internal/model" +) + +const ( + // circuitThreshold 主数据源连续失败达到该次数后触发熔断。 + circuitThreshold = 3 + // circuitCooldown 熔断持续时间,之后放行一次探测请求。 + circuitCooldown = 10 * time.Minute +) + +// New 创建主备切换数据源。 +// primary 为主数据源(如东财),secondary 为备用数据源(如腾讯)。 +func New(primary, secondary datasource.MarketDataSource) datasource.MarketDataSource { + return &dataSource{ + primary: primary, + secondary: secondary, + } +} + +// dataSource 包装主备两个数据源,实现 MarketDataSource 接口。 +type dataSource struct { + primary datasource.MarketDataSource + secondary datasource.MarketDataSource + + mu sync.Mutex + failCount int // 主数据源连续失败次数 + openSince time.Time // 熔断开启时间(open=true 时有效) + open bool // 熔断是否开启 +} + +// FetchConvertibles 拉取全量可转债实时行情:先尝试主数据源 push2 接口, +// 失败时走"东财 datacenter 列表 + 腾讯批量实时报价"的组合策略补充实时价格。 +// +// 主数据源 push2 故障是常见场景(东财对该集群限流较严),datacenter 与 push2 分属 +// 不同集群,通常仍可用,故组合策略能保证实时行情可用性。 +func (d *dataSource) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) { + bonds, err := d.primary.FetchConvertibles(ctx) + if err == nil { + return bonds, nil + } + + log.Printf("[fallback] 主数据源实时行情失败: %v,切换到组合策略(东财列表 + 腾讯报价)", err) + return d.fetchConvertiblesViaCombo(ctx) +} + +// fetchConvertiblesViaCombo 组合策略实时行情: +// 1. 东财 datacenter 拉转债列表(含正股代码、初始转股价、上市日期) +// 2. 腾讯批量实时接口(qt.gtimg.cn)补充实时价格和涨跌幅 +// 3. 过滤已退市转债(实时价格为 0) +func (d *dataSource) fetchConvertiblesViaCombo(ctx context.Context) ([]model.ConvertibleBond, error) { + em := eastmoney.NewClient() + bonds, err := em.FetchAllBondCodes(ctx) + if err != nil { + return nil, fmt.Errorf("datacenter 拉取列表失败: %w", err) + } + if len(bonds) == 0 { + return nil, fmt.Errorf("datacenter 返回空列表") + } + log.Printf("[fallback] datacenter 拉取到 %d 只转债,开始腾讯实时报价", len(bonds)) + + if err := tencent.FetchQuotes(ctx, bonds); err != nil { + // 腾讯报价失败不致命,已退市转债会被 price=0 过滤 + log.Printf("[fallback] 腾讯实时报价异常: %v", err) + } + + // 过滤已退市转债(实时价格为 0),与项目硬约束一致 + out := make([]model.ConvertibleBond, 0, len(bonds)) + for _, b := range bonds { + if b.Price > 0 { + out = append(out, b) + } + } + log.Printf("[fallback] 组合策略完成:有效转债 %d 只(已过滤退市 %d 只)", len(out), len(bonds)-len(out)) + return out, nil +} + +// FetchKline 获取历史K线:先尝试主数据源,失败后自动切换到备用数据源。 +// 熔断开启时直接使用备用数据源。 +func (d *dataSource) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) { + // 熔断开启:直接走备用数据源 + if d.circuitOpen() { + return d.fetchSecondary(ctx, code, klt, beg, end) + } + + klines, err := d.primary.FetchKline(ctx, code, klt, beg, end) + if err == nil { + // 主数据源恢复,重置失败计数 + d.recordResult(true) + return klines, nil + } + + log.Printf("[fallback] 主数据源 K线失败 (%s %s %s~%s): %v,切换到备用数据源", code, klt, beg, end, err) + d.recordResult(false) + return d.fetchSecondary(ctx, code, klt, beg, end) +} + +// fetchSecondary 从备用数据源获取K线并记录错误。 +func (d *dataSource) fetchSecondary(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) { + klines, err := d.secondary.FetchKline(ctx, code, klt, beg, end) + if err != nil { + return nil, fmt.Errorf("备用数据源: %w", err) + } + return klines, nil +} + +// circuitOpen 判断是否处于熔断开启状态;熔断超时后自动放行探测。 +func (d *dataSource) circuitOpen() bool { + d.mu.Lock() + defer d.mu.Unlock() + if !d.open { + return false + } + if time.Since(d.openSince) >= circuitCooldown { + // 熔断窗口结束,放行一次探测请求 + d.open = false + d.failCount = 0 + log.Printf("[fallback] 熔断窗口结束,放行主数据源探测") + return false + } + return true +} + +// recordResult 记录主数据源请求结果:失败达到阈值时开启熔断。 +func (d *dataSource) recordResult(ok bool) { + d.mu.Lock() + defer d.mu.Unlock() + if ok { + d.failCount = 0 + if d.open { + d.open = false + log.Printf("[fallback] 主数据源恢复,解除熔断") + } + return + } + d.failCount++ + if d.open { + return + } + if d.failCount >= circuitThreshold { + d.open = true + d.openSince = time.Now() + log.Printf("[fallback] 主数据源连续失败 %d 次,开启熔断 %v,直接使用备用数据源", d.failCount, circuitCooldown) + } +} diff --git a/internal/datasource/sina/convert.go b/internal/datasource/sina/convert.go new file mode 100644 index 0000000..208d799 --- /dev/null +++ b/internal/datasource/sina/convert.go @@ -0,0 +1,125 @@ +// Package sina 新浪财经数据源:提供可转债转股价变动历史。 +// 数据来源:money.finance.sina.com.cn/bond/conversion/{market}{code}.html(转股价变动表), +// 含初始转股价与历次调整/修正记录,字段:生效日期 + 转股价。 +package sina + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" + + "stock-analysis/internal/model" +) + +// conversionBase 新浪转债行权详情页(含转股价变动历史)。 +const conversionBase = "http://money.finance.sina.com.cn/bond/conversion/%s%s.html" + +var ( + bluecntRowRe = regexp.MustCompile(`(.*?)`) + cellRe = regexp.MustCompile(`]*>(.*?)`) + stripTagRe = regexp.MustCompile(`<[^>]+>`) + spaceRe = regexp.MustCompile(`\s+`) +) + +// Client 是新浪财经客户端。 +type Client struct { + http *http.Client +} + +// NewClient 创建新浪财经客户端。 +func NewClient() *Client { + return &Client{http: &http.Client{Timeout: 15 * time.Second}} +} + +// FetchConvertPrices 拉取指定转债的转股价变动历史(含初始转股价)。 +// 返回按生效日期升序的记录;页面缺失时返回空切片(由调用方用初始转股价兜底)。 +func (c *Client) FetchConvertPrices(ctx context.Context, code string) ([]model.ConvertPrice, error) { + reqURL := fmt.Sprintf(conversionBase, symbolOf(code), code) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http status %d", resp.StatusCode) + } + + // 新浪页面为 GBK 编码,转成 UTF-8 后解析。 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + utf8Body, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), body) + if err != nil { + return nil, fmt.Errorf("decode gbk: %w", err) + } + + return parseConvertPrices(string(utf8Body)), nil +} + +// parseConvertPrices 从 HTML 中提取转股价变动表的记录。 +// 仅解析"转股价变动 begin/end"注释区间内的数据行,避免误取行权/回售表。 +func parseConvertPrices(html string) []model.ConvertPrice { + const beginMark = "" + const endMark = "" + beginIdx := strings.Index(html, beginMark) + if beginIdx < 0 { + return nil + } + seg := html[beginIdx+len(beginMark):] + if endIdx := strings.Index(seg, endMark); endIdx >= 0 { + seg = seg[:endIdx] + } + + var prices []model.ConvertPrice + for _, row := range bluecntRowRe.FindAllString(seg, -1) { + cells := cellsOf(row) + // 列:序号 | 价格变动类型 | 公告日期 | 生效日期 | 执行日期 | 转股价 | 转股比例 + if len(cells) < 6 { + continue + } + effective := strings.TrimSpace(cells[3]) + price, err := strconv.ParseFloat(strings.TrimSpace(cells[5]), 64) + if err != nil || effective == "" || price <= 0 { + continue + } + prices = append(prices, model.ConvertPrice{ + EffectiveDate: effective, + Price: price, + }) + } + return prices +} + +// cellsOf 解析一行 中所有 的文本(去标签去空白)。 +func cellsOf(row string) []string { + var cells []string + for _, m := range cellRe.FindAllStringSubmatch(row, -1) { + text := stripTagRe.ReplaceAllString(m[1], "") + text = spaceRe.ReplaceAllString(text, "") + cells = append(cells, text) + } + return cells +} + +// symbolOf 生成新浪标的符号(沪市 sh / 深市 sz),与腾讯规则一致。 +func symbolOf(code string) string { + if strings.HasPrefix(code, "12") { + return "sz" + } + return "sh" +} diff --git a/internal/datasource/tencent/client.go b/internal/datasource/tencent/client.go new file mode 100644 index 0000000..9912515 --- /dev/null +++ b/internal/datasource/tencent/client.go @@ -0,0 +1,217 @@ +// Package tencent 实现 datasource.MarketDataSource 接口(腾讯财经行情源)。 +// 历史K线来源:web.ifzq.gtimg.cn(fqkline 接口),作为东财接口被限流时的备用数据源。 +// 注意:该接口为非官方免费接口,仅支持日K线,不支持全量转债列表。 +package tencent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "stock-analysis/internal/model" +) + +const ( + // klineHosts 腾讯历史K线接口域名列表。 + // web 域名对深市转债返回 501,proxy 域名可用;按顺序轮询,失败自动切换。 + klineHosts = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get|https://proxy.finance.qq.com/ifzqgtimg/appstock/app/fqkline/get" + // batchSize 单次请求最大K线条数(腾讯上限约 640)。 + batchSize = 640 +) + +// ErrConvertiblesUnsupported 腾讯行情源不支持全量转债列表。 +var ErrConvertiblesUnsupported = errors.New("tencent datasource: convertibles list not supported") + +// fqklineResp 是腾讯 fqkline 接口响应结构。 +// 除权除息日的K线会在第7列附加除权信息对象(如 {"fh_sh":"1.2"}), +// 因此用 json.RawMessage 承接整行,解析时只取前6列行情字段。 +type fqklineResp struct { + Code int `json:"code"` + Data map[string]struct { + Day [][]json.RawMessage `json:"day"` + QfqDay [][]json.RawMessage `json:"qfqday"` + } `json:"data"` +} + +// Client 是腾讯行情客户端,实现 datasource.MarketDataSource 的 K线能力。 +type Client struct { + http *http.Client +} + +// NewClient 创建腾讯行情客户端。 +func NewClient() *Client { + return &Client{ + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// FetchConvertibles 腾讯行情源不支持全量转债列表。 +func (c *Client) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) { + return nil, ErrConvertiblesUnsupported +} + +// FetchKline 拉取指定标的历史K线。 +// code 为标的代码(如 110062),klt 仅支持日K("101"),beg/end 形如 20200101。 +// 腾讯单次最多返回 batchSize(640) 根,超过时按日期窗口循环翻页。 +// 返回按时间升序排列的K线;成交额腾讯接口不提供,置 0。 +func (c *Client) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) { + symbol := symbolOf(code) + start := formatDate(beg) + endDate := formatDate(end) + + var all []model.Kline + for { + reqURLs := make([]string, 0, 2) + for _, host := range strings.Split(klineHosts, "|") { + reqURLs = append(reqURLs, fmt.Sprintf("%s?param=%s,day,%s,%s,%d,qfq", + host, symbol, start, endDate, batchSize)) + } + + raw, err := c.fetchBatch(ctx, reqURLs) + if err != nil { + return nil, err + } + if len(raw) == 0 { + break + } + + for _, row := range raw { + // 格式:[日期, 开盘, 收盘, 最高, 最低, 成交量(, 除权信息对象)] + if len(row) < 6 { + continue + } + k, err := parseRow(row) + if err != nil { + continue + } + all = append(all, k) + } + + // 未满一批说明已到结尾;否则从最后日期次日继续翻页。 + if len(raw) < batchSize { + break + } + last := all[len(all)-1].Date + next, err := nextDay(last) + if err != nil { + return nil, err + } + if next >= endDate { + break + } + start = next + } + return all, nil +} + +// fetchBatch 依次尝试各域名拉取一批K线。 +// 全部域名失败返回错误;有域名返回空数据(如已退市债)视为正常,返回 nil。 +func (c *Client) fetchBatch(ctx context.Context, reqURLs []string) ([][]json.RawMessage, error) { + var lastErr error + for _, reqURL := range reqURLs { + var resp fqklineResp + if err := c.get(ctx, reqURL, &resp); err != nil { + lastErr = err + continue + } + if resp.Code != 0 { + lastErr = fmt.Errorf("tencent kline code=%d", resp.Code) + continue + } + if len(resp.Data) == 0 { + lastErr = fmt.Errorf("tencent kline empty data") + continue + } + for _, bond := range resp.Data { + if raw := bond.Day; len(raw) > 0 { + return raw, nil + } + if raw := bond.QfqDay; len(raw) > 0 { + return raw, nil + } + } + // 域名返回成功但无K线(已退市债/未上市债):视为正常空数据。 + return nil, nil + } + return nil, lastErr +} + +// nextDay 返回 2006-01-02 格式日期的下一天。 +func nextDay(date string) (string, error) { + t, err := time.Parse("2006-01-02", date) + if err != nil { + return "", err + } + return t.AddDate(0, 0, 1).Format("2006-01-02"), nil +} + +// symbolOf 根据代码生成腾讯标的符号(沪市 sh / 深市 sz)。 +// 深市:12(可转债)、00/002/003(主板)、30(创业板)、20(B股);其余为沪市。 +func symbolOf(code string) string { + if strings.HasPrefix(code, "12") || strings.HasPrefix(code, "00") || + strings.HasPrefix(code, "30") || strings.HasPrefix(code, "20") { + return "sz" + code + } + return "sh" + code +} + +// formatDate 将 20060102 转为腾讯接口要求的 2006-01-02。 +func formatDate(d string) string { + if len(d) != 8 { + return d + } + return d[:4] + "-" + d[4:6] + "-" + d[6:8] +} + +// parseRow 解析腾讯K线行数据(前6列;第7列起的除权信息对象忽略)。 +func parseRow(row []json.RawMessage) (model.Kline, error) { + var fields [6]string + for i := 0; i < 6; i++ { + if err := json.Unmarshal(row[i], &fields[i]); err != nil { + return model.Kline{}, fmt.Errorf("bad tencent kline field %d: %w", i, err) + } + } + open, err1 := strconv.ParseFloat(fields[1], 64) + close_, err2 := strconv.ParseFloat(fields[2], 64) + high, err3 := strconv.ParseFloat(fields[3], 64) + low, err4 := strconv.ParseFloat(fields[4], 64) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil { + return model.Kline{}, fmt.Errorf("bad tencent kline numbers") + } + // 腾讯成交量为字符串格式(如 "206806.000"),需先转 float 再取整。 + vol, _ := strconv.ParseFloat(fields[5], 64) + return model.Kline{ + Date: fields[0], + Open: open, + Close: close_, + High: high, + Low: low, + Volume: int64(vol), + Amount: 0, // 腾讯接口不提供成交额 + }, nil +} + +// get 执行 HTTP GET 并解析 JSON。 +func (c *Client) get(ctx context.Context, reqURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return err + } + // 模拟浏览器 UA。 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http status %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/datasource/tencent/realtime.go b/internal/datasource/tencent/realtime.go new file mode 100644 index 0000000..f6094cd --- /dev/null +++ b/internal/datasource/tencent/realtime.go @@ -0,0 +1,137 @@ +package tencent + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "stock-analysis/internal/model" +) + +// quoteHost 腾讯实时行情接口域名(qt.gtimg.cn)。 +// 支持批量请求:q=sh113667,sh110062,sz123001;单次建议不超过 100 只。 +const quoteHost = "https://qt.gtimg.cn" + +// quoteBatchSize 腾讯实时行情单次请求的最大标的数量。 +// 实测 50 只稳定,超过 100 可能触发限流。 +const quoteBatchSize = 50 + +// quoteClient 腾讯实时行情专用 HTTP 客户端(与 K 线客户端隔离,避免共享限流计数)。 +var quoteClient = &http.Client{Timeout: 10 * time.Second} + +// FetchQuotes 批量拉取转债实时报价,回填到 bonds 中各标的的 Price 和 ChangePct 字段。 +// +// 用途:作为东财 push2 实时行情接口故障时的备用报价源。 +// 调用方(如 fallback 包)先用东财 datacenter 拿到转债列表,再用本方法补充实时价格。 +// +// 接口:https://qt.gtimg.cn/q=sh113667,sh110062,...(GET) +// 响应每行一只,字段以 `~` 分隔,0 起始索引:1=名称, 2=代码, 3=当前价, 4=昨收, 31=涨跌幅。 +// +// 已退市/停牌/未上市转债当前价为 0,调用方可据此过滤。 +func FetchQuotes(ctx context.Context, bonds []model.ConvertibleBond) error { + if len(bonds) == 0 { + return nil + } + + for i := 0; i < len(bonds); i += quoteBatchSize { + end := i + quoteBatchSize + if end > len(bonds) { + end = len(bonds) + } + batch := bonds[i:end] + + symbols := make([]string, 0, len(batch)) + for _, b := range batch { + symbols = append(symbols, symbolOf(b.Code)) + } + reqURL := fmt.Sprintf("%s/q=%s", quoteHost, strings.Join(symbols, ",")) + + body, err := httpGet(ctx, quoteClient, reqURL) + if err != nil { + // 单批失败不中断整体流程,已退市标的会被调用方按 price=0 过滤。 + continue + } + parseQuoteBatch(body, batch) + } + return nil +} + +// parseQuoteBatch 解析腾讯批量实时行情响应,回填到 batch 中各转债的 Price/ChangePct 字段。 +// +// 响应格式(每行一只,以 `;` 分隔): +// +// v_sh113667="1~春23转债~113667~210.976~214.702~...~-3.726~-1.74~..."; +// v_sz123001="51~蓝标转债~123001~135.998~135.998~...~0.000~0.00~..."; +// +// 字段以 `~` 分隔,0 起始索引: +// +// 0: 市场标识(1=沪, 51=深), 1: 名称, 2: 代码, 3: 当前价, 4: 昨收 +// 30: 时间戳(YYYYMMDDHHmmss), 31: 涨跌额, 32: 涨跌幅(%) +// 腾讯按 q 参数顺序返回,行数与 batch 对应(无数据标的返回 "v_=;")。 +func parseQuoteBatch(body string, batch []model.ConvertibleBond) { + // 腾讯响应以 ";\n" 或 ";" 分隔,统一去重空行 + body = strings.ReplaceAll(body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\n", "") + lines := strings.Split(body, ";") + + for i := range batch { + if i >= len(lines) { + break + } + price, chgPct := parseQuoteLine(lines[i]) + if price > 0 { + batch[i].Price = price + batch[i].ChangePct = chgPct + } + } +} + +// parseQuoteLine 解析单行腾讯实时行情,返回 (当前价, 涨跌幅%)。 +// 无法解析或无数据返回 (0, 0)。 +func parseQuoteLine(line string) (price, changePct float64) { + // 行格式:v_sh113667="1~春23转债~113667~210.976~..."; + start := strings.Index(line, "\"") + if start < 0 { + return 0, 0 + } + end := strings.LastIndex(line, "\"") + if end <= start { + return 0, 0 + } + content := line[start+1 : end] + fields := strings.Split(content, "~") + // 字段:0=市场标识, 1=名称, 2=代码, 3=当前价, 4=昨收, ..., 31=涨跌额, 32=涨跌幅 + if len(fields) < 33 { + return 0, 0 + } + price, _ = strconv.ParseFloat(fields[3], 64) + changePct, _ = strconv.ParseFloat(fields[32], 64) + return price, changePct +} + +// httpGet 执行 GET 请求并返回响应体字符串。 +func httpGet(ctx context.Context, client *http.Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("http status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/model/convertible.go b/internal/model/convertible.go new file mode 100644 index 0000000..33eb588 --- /dev/null +++ b/internal/model/convertible.go @@ -0,0 +1,79 @@ +// Package model 定义领域模型,是 MVC 架构中的 Model 层核心, +// 与具体数据源(东财接口、数据库)解耦。 +package model + +// ConvertibleBond 表示可转债实时行情。 +// 字段映射已对照东方财富数据中心(data.eastmoney.com/kzz/detail)逐一实测确认。 +type ConvertibleBond struct { + Code string // 转债代码 + Name string // 转债名称 + Price float64 // 转债最新价 + ChangePct float64 // 转债涨跌幅(%) + ListDate string // 上市日期(格式 2006-01-02) + DelistDate string // 退市日期(格式 2006-01-02,未退市为空) + StockCode string // 正股代码 + StockName string // 正股名称 + StockPrice float64 // 正股最新价 + StockChangePct float64 // 正股涨跌幅(%) + ConvertPrice float64 // 当前转股价 + InitialConvertPrice float64 // 初始转股价 + ConvertValue float64 // 转股价值 + PremiumRate float64 // 转股溢价率(%) + PureBondVal float64 // 纯债价值 + PureBondPremium float64 // 纯债溢价率(%) + ResaleTrigPrice float64 // 回售触发价 + RedeemTrigPrice float64 // 强赎触发价 +} + +// ConvertPrice 表示一次转股价变动(含初始转股价)。 +type ConvertPrice struct { + Code string // 转债代码 + EffectiveDate string // 转股价生效日期(格式 2006-01-02) + Price float64 // 转股价(元) +} + +// Kline 表示单根K线数据。 +type Kline struct { + Date string // 日期(格式 2006-01-02) + Open float64 // 开盘价 + Close float64 // 收盘价 + High float64 // 最高价 + Low float64 // 最低价 + Volume int64 // 成交量(手) + Amount float64 // 成交额(元) + ConvertPrice float64 // 当日转股价(元) + ConvertValue float64 // 转股价值(元) + PremiumRate float64 // 转股溢价率(%) +} + +// StockFundamental 表示正股基本面数据(用于信用风险过滤)。 +type StockFundamental struct { + Code string // 股票代码 + Name string // 股票名称 + ROE float64 // 净资产收益率(%) + DebtRatio float64 // 资产负债率(%) + NetProfit float64 // 净利润(元) + Status int // 上市状态:1=正常, 2=ST, 3=*ST + TotalAsset float64 // 总资产(元) + TotalDebt float64 // 总负债(元) + UpdateDate string // 数据更新日期 +} + +// User 表示系统注册用户。 +type User struct { + ID int64 + Username string // 用户名,用于密码登录 + Email string // 邮箱,用于验证码登录 + PasswordHash string + CreatedAt string + UpdatedAt string +} + +// VerifyCode 表示邮箱验证码。 +type VerifyCode struct { + Email string + Code string + Purpose string // register / login / reset + ExpiresAt int64 // unix timestamp + Used bool +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..c381abb --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,71 @@ +// Package router 负责 HTTP 路由注册,将请求分发到 Controller。 +package router + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + + "stock-analysis/internal/controller" +) + +// New 构建应用路由处理器。 +// webDir 为前端静态资源目录(View 层)。 +func New(mc *controller.MarketController, ac *controller.AuthController, webDir string) http.Handler { + mux := http.NewServeMux() + + // 公开接口:无需登录。 + mux.HandleFunc("POST /api/auth/send-code", ac.SendCode) + mux.HandleFunc("POST /api/auth/register", ac.Register) + mux.HandleFunc("POST /api/auth/login", ac.Login) + + // 受保护接口:需要登录。 + mux.HandleFunc("GET /api/auth/me", ac.Me) + mux.HandleFunc("POST /api/auth/logout", ac.Logout) + mux.HandleFunc("GET /api/bonds", requireAuth(ac, mc.ListBonds)) + mux.HandleFunc("GET /api/bonds/{code}/kline", requireAuth(ac, mc.GetKline)) + mux.HandleFunc("POST /api/backtest", requireAuth(ac, mc.RunBacktest)) + mux.HandleFunc("GET /api/status", requireAuth(ac, mc.GetDataStatus)) + mux.HandleFunc("GET /api/db/bonds", requireAuth(ac, mc.ListDBBonds)) + + // 静态资源:登录页及样式脚本公开,首页受保护。 + fs := noCache(http.FileServer(http.Dir(webDir))) + mux.Handle("/auth.html", fs) + mux.Handle("/style.css", fs) + mux.HandleFunc("/", requireAuth(ac, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + })) + return mux +} + +// requireAuth 中间件:未登录的 API 请求返回 401,页面请求重定向到登录页。 +func requireAuth(ac *controller.AuthController, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, err := ac.CurrentUser(r); err != nil { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) + } else { + http.Redirect(w, r, "/auth.html", http.StatusFound) + } + return + } + next.ServeHTTP(w, r) + } +} + +// noCache 中间件:禁止浏览器缓存静态资源,确保每次加载最新版本。 +func noCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..c7881e4 --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,309 @@ +// Package service 提供用户注册/登录与验证码服务。 +package service + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "log" + "math/big" + "net/mail" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + + "stock-analysis/internal/model" +) + +// AuthService 用户认证服务。 +type AuthService struct { + store AuthStore + // 邮件发送器,未配置时使用控制台打印。 + mailer Mailer + // 验证码有效期。 + codeTTL time.Duration + + tokenMu sync.RWMutex + tokens map[string]int64 // token -> userID +} + +// AuthStore 定义认证所需的持久化接口。 +type AuthStore interface { + SaveUser(ctx context.Context, u model.User) (int64, error) + GetUserByID(ctx context.Context, id int64) (*model.User, error) + GetUserByEmail(ctx context.Context, email string) (*model.User, error) + GetUserByUsername(ctx context.Context, username string) (*model.User, error) + SaveVerifyCode(ctx context.Context, v model.VerifyCode) error + GetLatestVerifyCode(ctx context.Context, email, purpose string) (*model.VerifyCode, error) + MarkVerifyCodeUsed(ctx context.Context, email, code, purpose string) error +} + +// Mailer 邮件发送接口。 +type Mailer interface { + SendCode(email, code string) error +} + +// consoleMailer 默认邮件发送器,仅打印到控制台。 +type consoleMailer struct{} + +func (consoleMailer) SendCode(email, code string) error { + fmt.Printf("[验证码] 邮箱: %s 验证码: %s\n", email, code) + return nil +} + +// NewAuthService 创建认证服务。 +func NewAuthService(store AuthStore) *AuthService { + return &AuthService{ + store: store, + mailer: consoleMailer{}, + codeTTL: 10 * time.Minute, + tokens: make(map[string]int64), + } +} + +// SetMailer 设置外部邮件发送器。 +func (s *AuthService) SetMailer(m Mailer) { s.mailer = m } + +// SendVerifyCode 发送邮箱验证码。 +func (s *AuthService) SendVerifyCode(ctx context.Context, email, purpose string) error { + email = normalizeEmail(email) + if !isValidEmail(email) { + return fmt.Errorf("邮箱格式不正确") + } + if purpose != "register" && purpose != "login" && purpose != "reset" { + purpose = "register" + } + + code := randomCode(6) + v := model.VerifyCode{ + Email: email, + Code: code, + Purpose: purpose, + ExpiresAt: time.Now().Add(s.codeTTL).Unix(), + } + if err := s.store.SaveVerifyCode(ctx, v); err != nil { + return fmt.Errorf("保存验证码失败: %w", err) + } + if s.mailer != nil { + if err := s.mailer.SendCode(email, code); err != nil { + return fmt.Errorf("发送验证码失败: %w", err) + } + } + return nil +} + +// Register 邮箱+验证码注册,包含用户名。 +func (s *AuthService) Register(ctx context.Context, username, email, code, password string) (*model.User, error) { + email = normalizeEmail(email) + if !isValidEmail(email) { + return nil, fmt.Errorf("邮箱格式不正确") + } + if len(password) < 6 { + return nil, fmt.Errorf("密码长度不能少于6位") + } + username = strings.TrimSpace(username) + if username == "" { + return nil, fmt.Errorf("用户名不能为空") + } + + if err := s.verifyCode(ctx, email, code, "register"); err != nil { + return nil, err + } + + existing, err := s.store.GetUserByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("查询用户失败: %w", err) + } + if existing != nil { + return nil, fmt.Errorf("该邮箱已注册") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %w", err) + } + + now := time.Now().Format("2006-01-02 15:04:05") + u := model.User{ + Username: username, + Email: email, + PasswordHash: string(hash), + CreatedAt: now, + UpdatedAt: now, + } + id, err := s.store.SaveUser(ctx, u) + if err != nil { + return nil, fmt.Errorf("保存用户失败: %w", err) + } + u.ID = id + + // 标记验证码已使用。 + _ = s.store.MarkVerifyCodeUsed(ctx, email, code, "register") + return &u, nil +} + +// Login 邮箱+验证码登录。 +func (s *AuthService) Login(ctx context.Context, email, code string) (*model.User, string, error) { + email = normalizeEmail(email) + if !isValidEmail(email) { + return nil, "", fmt.Errorf("邮箱格式不正确") + } + + if err := s.verifyCode(ctx, email, code, "login"); err != nil { + return nil, "", err + } + + u, err := s.store.GetUserByEmail(ctx, email) + if err != nil { + return nil, "", fmt.Errorf("查询用户失败: %w", err) + } + if u == nil { + return nil, "", fmt.Errorf("该邮箱未注册") + } + + _ = s.store.MarkVerifyCodeUsed(ctx, email, code, "login") + token := s.issueToken(u.ID) + return u, token, nil +} + +// LoginByUsername 用户名+密码登录。 +func (s *AuthService) LoginByUsername(ctx context.Context, username, password string) (*model.User, string, error) { + username = strings.TrimSpace(username) + if username == "" { + return nil, "", fmt.Errorf("用户名不能为空") + } + u, err := s.store.GetUserByUsername(ctx, username) + if err != nil { + return nil, "", fmt.Errorf("查询用户失败: %w", err) + } + if u == nil { + return nil, "", fmt.Errorf("用户名或密码错误") + } + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { + return nil, "", fmt.Errorf("用户名或密码错误") + } + token := s.issueToken(u.ID) + return u, token, nil +} + +// EnsureAdmin 确保 admin 用户存在,不存在则创建。 +func (s *AuthService) EnsureAdmin(ctx context.Context) error { + u, err := s.store.GetUserByUsername(ctx, "admin") + if err != nil { + return fmt.Errorf("查询admin: %w", err) + } + if u != nil { + return nil // 已存在 + } + hash, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("加密admin密码: %w", err) + } + now := time.Now().Format("2006-01-02 15:04:05") + admin := model.User{ + Username: "admin", + Email: "admin@stock.local", + PasswordHash: string(hash), + CreatedAt: now, + UpdatedAt: now, + } + if _, err := s.store.SaveUser(ctx, admin); err != nil { + return fmt.Errorf("创建admin: %w", err) + } + log.Printf("[auth] admin 管理员账户已创建(用户名: admin, 密码: 123456)") + return nil +} + +// issueToken 生成并保存会话 token。 +func (s *AuthService) issueToken(userID int64) string { + token, _ := RandomToken(32) + s.tokenMu.Lock() + s.tokens[token] = userID + s.tokenMu.Unlock() + return token +} + +// ValidateToken 校验 token 并返回对应用户。 +func (s *AuthService) ValidateToken(ctx context.Context, token string) (*model.User, error) { + s.tokenMu.RLock() + userID, ok := s.tokens[token] + s.tokenMu.RUnlock() + if !ok { + return nil, fmt.Errorf("未登录或登录已过期") + } + u, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("查询用户失败: %w", err) + } + if u == nil { + return nil, fmt.Errorf("用户不存在") + } + return u, nil +} + +// Logout 注销指定 token。 +func (s *AuthService) Logout(token string) { + s.tokenMu.Lock() + delete(s.tokens, token) + s.tokenMu.Unlock() +} + +// verifyCode 校验验证码。 +func (s *AuthService) verifyCode(ctx context.Context, email, code, purpose string) error { + stored, err := s.store.GetLatestVerifyCode(ctx, email, purpose) + if err != nil { + return fmt.Errorf("查询验证码失败: %w", err) + } + if stored == nil { + return fmt.Errorf("验证码不存在,请先获取") + } + if stored.Used { + return fmt.Errorf("验证码已使用") + } + if time.Now().Unix() > stored.ExpiresAt { + return fmt.Errorf("验证码已过期") + } + if subtle.ConstantTimeCompare([]byte(stored.Code), []byte(code)) != 1 { + return fmt.Errorf("验证码错误") + } + return nil +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + +func isValidEmail(email string) bool { + if !emailRegex.MatchString(email) { + return false + } + _, err := mail.ParseAddress(email) + return err == nil +} + +// randomCode 生成指定长度的数字验证码。 +func randomCode(length int) string { + const digits = "0123456789" + var b strings.Builder + for i := 0; i < length; i++ { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(digits)))) + b.WriteByte(digits[n.Int64()]) + } + return b.String() +} + +// RandomToken 生成随机 token。 +func RandomToken(length int) (string, error) { + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} \ No newline at end of file diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go new file mode 100644 index 0000000..6ff55ca --- /dev/null +++ b/internal/service/auth_test.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "os" + "testing" + + "stock-analysis/internal/model" + "stock-analysis/internal/storage/sqlite" +) + +type testMailer struct { + lastEmail string + lastCode string +} + +func (m *testMailer) SendCode(email, code string) error { + m.lastEmail = email + m.lastCode = code + return nil +} + +func TestAuthService(t *testing.T) { + ctx := context.Background() + dbPath := "test_auth.db" + _ = os.Remove(dbPath) + defer os.Remove(dbPath) + + store, err := sqlite.Open(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer store.Close() + + svc := NewAuthService(store) + mailer := &testMailer{} + svc.SetMailer(mailer) + + email := "Test@Example.COM " + normalized := "test@example.com" + username := "testuser" + + // 1. 发送注册验证码 + if err := svc.SendVerifyCode(ctx, email, "register"); err != nil { + t.Fatalf("send register code: %v", err) + } + if mailer.lastEmail != normalized { + t.Fatalf("email not normalized: got %s want %s", mailer.lastEmail, normalized) + } + if len(mailer.lastCode) != 6 { + t.Fatalf("code length invalid: %s", mailer.lastCode) + } + + // 2. 验证码注册(含用户名) + u, err := svc.Register(ctx, username, email, mailer.lastCode, "123456") + if err != nil { + t.Fatalf("register: %v", err) + } + if u.Email != normalized { + t.Fatalf("registered email: got %s want %s", u.Email, normalized) + } + if u.Username != username { + t.Fatalf("registered username: got %s want %s", u.Username, username) + } + + // 3. 重复注册应失败 + if _, err := svc.Register(ctx, username, email, mailer.lastCode, "123456"); err == nil { + t.Fatal("duplicate register should fail") + } + + // 4. 发送登录验证码 + if err := svc.SendVerifyCode(ctx, email, "login"); err != nil { + t.Fatalf("send login code: %v", err) + } + + // 5. 验证码登录 + logged, token, err := svc.Login(ctx, email, mailer.lastCode) + if err != nil { + t.Fatalf("login: %v", err) + } + if logged.ID != u.ID { + t.Fatal("login user id mismatch") + } + if token == "" { + t.Fatal("login token empty") + } + + // 6. 用户名+密码登录 + logged2, token2, err := svc.LoginByUsername(ctx, username, "123456") + if err != nil { + t.Fatalf("login by username: %v", err) + } + if logged2.ID != u.ID { + t.Fatal("username login user id mismatch") + } + if token2 == "" { + t.Fatal("username login token empty") + } + + // 7. token 校验 + validated, err := svc.ValidateToken(ctx, token2) + if err != nil { + t.Fatalf("validate token: %v", err) + } + if validated.ID != u.ID { + t.Fatal("validated user id mismatch") + } + + // 8. 退出后 token 失效 + svc.Logout(token2) + if _, err := svc.ValidateToken(ctx, token2); err == nil { + t.Fatal("token should be invalid after logout") + } + + // 9. 密码错误 + if _, _, err := svc.LoginByUsername(ctx, username, "wrong"); err == nil { + t.Fatal("wrong password should fail") + } + + // 10. 验证码过期/错误 + if _, _, err := svc.Login(ctx, email, "000000"); err == nil { + t.Fatal("used/wrong code should fail") + } + + // 11. 不存在用户 + if _, _, err := svc.Login(ctx, "notfound@example.com", "123456"); err == nil { + t.Fatal("nonexistent user should fail") + } + + // 12. EnsureAdmin 创建 admin + if err := svc.EnsureAdmin(ctx); err != nil { + t.Fatalf("ensure admin: %v", err) + } + // 第二次调用应不报错(幂等) + if err := svc.EnsureAdmin(ctx); err != nil { + t.Fatalf("ensure admin idempotent: %v", err) + } + // admin 可以登录 + admin, adminToken, err := svc.LoginByUsername(ctx, "admin", "123456") + if err != nil { + t.Fatalf("admin login: %v", err) + } + if admin.Username != "admin" { + t.Fatal("admin username mismatch") + } + if adminToken == "" { + t.Fatal("admin token empty") + } + + // 验证 model 类型 + _ = model.User{} + _ = model.VerifyCode{} +} \ No newline at end of file diff --git a/internal/service/market.go b/internal/service/market.go new file mode 100644 index 0000000..39be14e --- /dev/null +++ b/internal/service/market.go @@ -0,0 +1,76 @@ +// Package service 定义业务逻辑层,位于 MVC 架构中 Controller 与 Model 之间。 +// 本层封装领域业务(行情、策略、回测),不感知 HTTP 与具体数据源实现。 +package service + +import ( + "context" + "fmt" + "time" + + "stock-analysis/internal/datasource" + "stock-analysis/internal/model" +) + +// MarketService 提供行情查询业务。 +type MarketService struct { + ds datasource.MarketDataSource +} + +// NewMarketService 创建行情服务。 +func NewMarketService(ds datasource.MarketDataSource) *MarketService { + return &MarketService{ds: ds} +} + +// ListConvertibles 返回全量可转债实时行情(过滤已退市转债)。 +func (s *MarketService) ListConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) { + all, err := s.ds.FetchConvertibles(ctx) + if err != nil { + return nil, err + } + // 过滤掉已退市的转债:退市后东财接口返回的价格为 0。 + var active []model.ConvertibleBond + for _, b := range all { + if b.Price > 0 { + active = append(active, b) + } + } + return active, nil +} + +// GetDailyKline 返回指定可转债的日K线(自 beg 至今)。 +func (s *MarketService) GetDailyKline(ctx context.Context, code, beg string) ([]model.Kline, error) { + // klt=101 为日K,beg/end 格式 20060102;end 取极大值表示拉取至今。 + return s.ds.FetchKline(ctx, code, "101", beg, "20500101") +} + +// FetchAllDailyKline 按年度窗口分批拉取指定标的的全量日K线。 +// fromYear 为起始年份(如烽火转债 2020),到当前年份为止,保证单次请求数据量可控。 +func (s *MarketService) FetchAllDailyKline(ctx context.Context, code string, fromYear int) ([]model.Kline, error) { + curYear := time.Now().Year() + var all []model.Kline + for year := fromYear; year <= curYear; year++ { + beg := fmt.Sprintf("%04d0101", year) + end := fmt.Sprintf("%04d1231", year) + ks, err := s.ds.FetchKline(ctx, code, "101", beg, end) + if err != nil { + return nil, fmt.Errorf("fetch kline %d: %w", year, err) + } + all = append(all, ks...) + } + return all, nil +} + +// FetchFullKline 单次请求拉取指定标的自 fromYear 起的全量日K线。 +// 适合批量入库场景:每只转债仅一次请求,大幅降低全市场抓取时的请求总量。 +// 可转债存续期最长约 6 年(约 1500 根),单次请求即可返回完整历史。 +func (s *MarketService) FetchFullKline(ctx context.Context, code string, fromYear int) ([]model.Kline, error) { + beg := fmt.Sprintf("%04d0101", fromYear) + return s.ds.FetchKline(ctx, code, "101", beg, "20500101") +} + +// FetchIncrementalKline 增量拉取日K线:从 begDate 起到当前日期。 +// begDate 格式为 YYYYMMDD(如 "20260801"),对应数据源 API 入参;end 取极大值表示拉取至今。 +// 用于增量入库:配合 LastKlineDate 使用,避免重复拉取已存档数据。 +func (s *MarketService) FetchIncrementalKline(ctx context.Context, code, begDate string) ([]model.Kline, error) { + return s.ds.FetchKline(ctx, code, "101", begDate, "20500101") +} diff --git a/internal/storage/kline.go b/internal/storage/kline.go new file mode 100644 index 0000000..fee9197 --- /dev/null +++ b/internal/storage/kline.go @@ -0,0 +1,27 @@ +// Package storage 定义持久化接口(MVC Model 层),与具体数据库实现解耦。 +// SQL 语句遵循通用标准语法(ANSI SQL),便于在 SQLite / PostgreSQL 之间迁移。 +package storage + +import ( + "context" + + "stock-analysis/internal/model" +) + +// KlineStore 日K线存储接口。 +type KlineStore interface { + // SaveKlines 幂等批量写入指定标的的K线(已存在的记录跳过)。 + SaveKlines(ctx context.Context, code string, klines []model.Kline) error + // GetKlines 按时间升序返回指定标的的全部K线。 + GetKlines(ctx context.Context, code string) ([]model.Kline, error) + // CountKlines 返回指定标的的K线条数。 + CountKlines(ctx context.Context, code string) (int, error) + // LastKlineDate 返回指定标的的最新K线日期(YYYY-MM-DD),无记录返回空串。 + LastKlineDate(ctx context.Context, code string) (string, error) + // ListCodes 返回库中全部标的代码。 + ListCodes(ctx context.Context) ([]string, error) + // LoadAllKlines 一次性加载全部标的的K线(map 键为标的代码)。 + LoadAllKlines(ctx context.Context) (map[string][]model.Kline, error) + // Close 关闭存储。 + Close() error +} diff --git a/internal/storage/sqlite/store.go b/internal/storage/sqlite/store.go new file mode 100644 index 0000000..4b7344d --- /dev/null +++ b/internal/storage/sqlite/store.go @@ -0,0 +1,682 @@ +// Package sqlite 实现 storage.KlineStore 接口,使用 SQLite 数据库。 +// 表结构与 DML 使用通用 SQL 语法(ON CONFLICT 为 SQLite 3.24+ 与 PostgreSQL 共有的标准写法), +// 迁移到 PostgreSQL 时仅需调整连接与参数占位符。 +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" // 纯 Go 驱动,无 CGO 依赖 + + "stock-analysis/internal/model" + "stock-analysis/internal/storage" +) + +// 通用 SQL:表结构。 +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS convertible_kline ( + code TEXT NOT NULL, + date TEXT NOT NULL, + open REAL NOT NULL, + close REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + volume INTEGER NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + convert_price REAL NOT NULL DEFAULT 0, + convert_value REAL NOT NULL DEFAULT 0, + premium_rate REAL NOT NULL DEFAULT 0, + PRIMARY KEY (code, date) +); +CREATE INDEX IF NOT EXISTS idx_kline_code_date ON convertible_kline (code, date); + +CREATE TABLE IF NOT EXISTS convert_bond ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + stock_code TEXT NOT NULL DEFAULT '', + initial_convert_price REAL NOT NULL DEFAULT 0, + list_date TEXT NOT NULL DEFAULT '', + delist_date TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS convert_price_history ( + code TEXT NOT NULL, + effective_date TEXT NOT NULL, + price REAL NOT NULL, + PRIMARY KEY (code, effective_date) + ); + + CREATE TABLE IF NOT EXISTS stock_fundamental ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + roe REAL NOT NULL DEFAULT 0, + debt_ratio REAL NOT NULL DEFAULT 0, + net_profit REAL NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + total_asset REAL NOT NULL DEFAULT 0, + total_debt REAL NOT NULL DEFAULT 0, + update_date TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS verify_code ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + code TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'register', + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_verify_code_email ON verify_code (email, purpose); + ` + +// 通用 SQL:幂等写入(存在则跳过)。 +const upsertSQL = ` +INSERT INTO convertible_kline (code, date, open, close, high, low, volume, amount, convert_price, convert_value, premium_rate) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (code, date) DO NOTHING; +` + +const ( + querySQL = `SELECT code, date, open, close, high, low, volume, amount, convert_price, convert_value, premium_rate FROM convertible_kline WHERE code = ? ORDER BY date;` + countSQL = `SELECT COUNT(*) FROM convertible_kline WHERE code = ?;` + lastDateSQL = `SELECT MAX(date) FROM convertible_kline WHERE code = ?;` + allKlineSQL = `SELECT code, date, open, close, high, low, volume, amount, convert_price, convert_value, premium_rate FROM convertible_kline ORDER BY code, date;` + + saveBondSQL = ` +INSERT INTO convert_bond (code, name, stock_code, initial_convert_price, list_date, delist_date) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (code) DO UPDATE SET + name = excluded.name, + stock_code = excluded.stock_code, + initial_convert_price = excluded.initial_convert_price, + list_date = excluded.list_date, + delist_date = excluded.delist_date;` + + saveConvertPriceSQL = ` +INSERT INTO convert_price_history (code, effective_date, price) +VALUES (?, ?, ?) +ON CONFLICT (code, effective_date) DO UPDATE SET price = excluded.price;` + + updatePremiumSQL = `UPDATE convertible_kline SET convert_price = ?, convert_value = ?, premium_rate = ? WHERE code = ? AND date = ?;` + + saveFundamentalSQL = ` + INSERT INTO stock_fundamental (code, name, roe, debt_ratio, net_profit, status, total_asset, total_debt, update_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (code) DO UPDATE SET + name = excluded.name, + roe = excluded.roe, + debt_ratio = excluded.debt_ratio, + net_profit = excluded.net_profit, + status = excluded.status, + total_asset = excluded.total_asset, + total_debt = excluded.total_debt, + update_date = excluded.update_date;` + + getFundamentalSQL = `SELECT code, name, roe, debt_ratio, net_profit, status, total_asset, total_debt, update_date FROM stock_fundamental WHERE code = ?;` + + allFundamentalSQL = `SELECT code, name, roe, debt_ratio, net_profit, status, total_asset, total_debt, update_date FROM stock_fundamental;` +) + +// PremiumCount 返回指定转债已计算转股溢价率的K线数量(步骤3断点续跑依据)。 +func (s *Store) PremiumCount(ctx context.Context, code string) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM convertible_kline WHERE code = ? AND premium_rate <> 0", code).Scan(&n) + return n, err +} + +// PremiumRow 表示单日溢价率计算结果(用于批量更新)。 +type PremiumRow struct { + Date string + ConvertPrice float64 + ConvertValue float64 + PremiumRate float64 +} + +// Store 是 SQLite 存储实现,实现 storage.KlineStore。 +type Store struct { + db *sql.DB +} + +// Open 打开(或创建)SQLite 数据库文件并初始化表结构。 +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite %s: %w", path, err) + } + // SQLite 同一时刻仅允许一个写者,限制连接数避免写锁竞争。 + db.SetMaxOpenConns(1) + + if err := initSchema(db); err != nil { + db.Close() + return nil, err + } + if err := migrateKlineColumns(db); err != nil { + db.Close() + return nil, err + } + if err := migrateUserColumns(db); err != nil { + db.Close() + return nil, err + } + // 关闭 journal,避免在受限文件系统上创建 journal 文件导致写库失败(SQLite_IOERR)。 + if _, err := db.Exec("PRAGMA journal_mode=OFF"); err != nil { + db.Close() + return nil, fmt.Errorf("set journal mode: %w", err) + } + // 禁用 mmap 读取:modernc 驱动默认内存映射数据库文件,在受限文件系统上会报 IOERR_READ。 + if _, err := db.Exec("PRAGMA mmap_size=0"); err != nil { + db.Close() + return nil, fmt.Errorf("set mmap size: %w", err) + } + // 等待锁:避免其他进程(杀软/索引)短暂占用文件时报错。 + if _, err := db.Exec("PRAGMA busy_timeout=8000"); err != nil { + db.Close() + return nil, fmt.Errorf("set busy timeout: %w", err) + } + return &Store{db: db}, nil +} + +// initSchema 执行建表语句。 +func initSchema(db *sql.DB) error { + if _, err := db.Exec(schemaSQL); err != nil { + return fmt.Errorf("init schema: %w", err) + } + return nil +} + +// migrateKlineColumns 为旧库的 convertible_kline 表补齐溢价率相关列。 +func migrateKlineColumns(db *sql.DB) error { + rows, err := db.Query("PRAGMA table_info(convertible_kline)") + if err != nil { + return fmt.Errorf("table info: %w", err) + } + defer rows.Close() + + have := make(map[string]bool) + for rows.Next() { + var ( + cid int + name, typ string + notnull int + dflt any + pk int + ) + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return fmt.Errorf("scan table info: %w", err) + } + have[name] = true + } + if err := rows.Err(); err != nil { + return err + } + + for _, col := range []string{"convert_price", "convert_value", "premium_rate"} { + if have[col] { + continue + } + if _, err := db.Exec(fmt.Sprintf("ALTER TABLE convertible_kline ADD COLUMN %s REAL NOT NULL DEFAULT 0", col)); err != nil { + return fmt.Errorf("add column %s: %w", col, err) + } + } + return nil +} + +// migrateUserColumns 为旧库的 user 表补齐 username 列并创建索引。 +func migrateUserColumns(db *sql.DB) error { + rows, err := db.Query("PRAGMA table_info(user)") + if err != nil { + return fmt.Errorf("table info user: %w", err) + } + hasUsername := false + for rows.Next() { + var cid int + var name, colType string + var notNull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil { + rows.Close() + return fmt.Errorf("scan column: %w", err) + } + if name == "username" { + hasUsername = true + } + } + rows.Close() + + if !hasUsername { + if _, err := db.Exec("ALTER TABLE user ADD COLUMN username TEXT NOT NULL DEFAULT ''"); err != nil { + return fmt.Errorf("add username column: %w", err) + } + } + // 始终创建索引(幂等)。 + if _, err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username ON user (username) WHERE username != ''"); err != nil { + return fmt.Errorf("create username index: %w", err) + } + return nil +} + +// SaveKlines 幂等批量写入K线。 +// 长进程中数据库文件可能被系统安全软件短暂占用导致 IOERR,此处带有限次重试。 +func (s *Store) SaveKlines(ctx context.Context, code string, klines []model.Kline) error { + if len(klines) == 0 { + return nil + } + const maxAttempt = 3 + var lastErr error + for attempt := 0; attempt < maxAttempt; attempt++ { + if attempt > 0 { + time.Sleep(2 * time.Second) + } + if err := s.saveOnce(ctx, code, klines); err != nil { + lastErr = err + continue + } + return nil + } + return lastErr +} + +// saveOnce 单次执行批量写入事务。 +func (s *Store) saveOnce(ctx context.Context, code string, klines []model.Kline) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck // 提交成功后回滚为无操作 + + stmt, err := tx.PrepareContext(ctx, upsertSQL) + if err != nil { + return fmt.Errorf("prepare upsert: %w", err) + } + defer stmt.Close() + + for _, k := range klines { + if _, err := stmt.ExecContext(ctx, code, k.Date, k.Open, k.Close, k.High, k.Low, k.Volume, k.Amount, k.ConvertPrice, k.ConvertValue, k.PremiumRate); err != nil { + return fmt.Errorf("insert kline %s %s: %w", code, k.Date, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit tx: %w", err) + } + return nil +} + +// GetKlines 按时间升序返回指定标的的全部K线。 +func (s *Store) GetKlines(ctx context.Context, code string) ([]model.Kline, error) { + rows, err := s.db.QueryContext(ctx, querySQL, code) + if err != nil { + return nil, fmt.Errorf("query klines: %w", err) + } + defer rows.Close() + + var klines []model.Kline + for rows.Next() { + k, code, err := scanKline(rows) + if err != nil { + return nil, err + } + _ = code + klines = append(klines, k) + } + return klines, rows.Err() +} + +// ListCodes 返回库中全部标的代码(升序)。 +func (s *Store) ListCodes(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT DISTINCT code FROM convertible_kline ORDER BY code") + if err != nil { + return nil, fmt.Errorf("list codes: %w", err) + } + defer rows.Close() + + var codes []string + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + return nil, fmt.Errorf("scan code: %w", err) + } + codes = append(codes, code) + } + return codes, rows.Err() +} + +// LoadAllKlines 一次性加载全部标的的K线(按代码、日期升序)。 +// 用于回测等需要全量数据在内存的场景。 +func (s *Store) LoadAllKlines(ctx context.Context) (map[string][]model.Kline, error) { + rows, err := s.db.QueryContext(ctx, allKlineSQL) + if err != nil { + return nil, fmt.Errorf("load all klines: %w", err) + } + defer rows.Close() + + out := make(map[string][]model.Kline) + for rows.Next() { + k, code, err := scanKline(rows) + if err != nil { + return nil, err + } + out[code] = append(out[code], k) + } + return out, rows.Err() +} + +// scanKline 从查询行扫描一条K线记录。 +func scanKline(rows *sql.Rows) (model.Kline, string, error) { + var ( + k model.Kline + code, date string + ) + if err := rows.Scan(&code, &date, &k.Open, &k.Close, &k.High, &k.Low, &k.Volume, &k.Amount, &k.ConvertPrice, &k.ConvertValue, &k.PremiumRate); err != nil { + return model.Kline{}, "", fmt.Errorf("scan kline: %w", err) + } + k.Date = date + return k, code, nil +} + +// CountKlines 返回指定标的的K线条数。 +func (s *Store) CountKlines(ctx context.Context, code string) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, countSQL, code).Scan(&n); err != nil { + return 0, fmt.Errorf("count klines: %w", err) + } + return n, nil +} + +// LastKlineDate 返回指定标的的最新K线日期(YYYY-MM-DD),无记录返回空串。 +func (s *Store) LastKlineDate(ctx context.Context, code string) (string, error) { + var date sql.NullString + if err := s.db.QueryRowContext(ctx, lastDateSQL, code).Scan(&date); err != nil { + return "", fmt.Errorf("last kline date: %w", err) + } + if !date.Valid { + return "", nil + } + return date.String, nil +} + +// SaveBonds 幂等写入转债基础信息(含正股代码/初始转股价)。 +func (s *Store) SaveBonds(ctx context.Context, bonds []model.ConvertibleBond) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + stmt, err := tx.PrepareContext(ctx, saveBondSQL) + if err != nil { + return fmt.Errorf("prepare save bond: %w", err) + } + defer stmt.Close() + + for _, b := range bonds { + if _, err := stmt.ExecContext(ctx, b.Code, b.Name, b.StockCode, b.InitialConvertPrice, b.ListDate, b.DelistDate); err != nil { + return fmt.Errorf("save bond %s: %w", b.Code, err) + } + } + return tx.Commit() +} + +// ListBonds 返回全部转债基础信息。 +func (s *Store) ListBonds(ctx context.Context) ([]model.ConvertibleBond, error) { + rows, err := s.db.QueryContext(ctx, "SELECT code, name, stock_code, initial_convert_price, list_date, delist_date FROM convert_bond ORDER BY code") + if err != nil { + return nil, fmt.Errorf("list bonds: %w", err) + } + defer rows.Close() + + var bonds []model.ConvertibleBond + for rows.Next() { + var b model.ConvertibleBond + if err := rows.Scan(&b.Code, &b.Name, &b.StockCode, &b.InitialConvertPrice, &b.ListDate, &b.DelistDate); err != nil { + return nil, fmt.Errorf("scan bond: %w", err) + } + bonds = append(bonds, b) + } + return bonds, rows.Err() +} + +// SaveConvertPrices 幂等写入转股价历史。 +func (s *Store) SaveConvertPrices(ctx context.Context, code string, prices []model.ConvertPrice) error { + if len(prices) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + stmt, err := tx.PrepareContext(ctx, saveConvertPriceSQL) + if err != nil { + return fmt.Errorf("prepare save convert price: %w", err) + } + defer stmt.Close() + + for _, p := range prices { + if _, err := stmt.ExecContext(ctx, code, p.EffectiveDate, p.Price); err != nil { + return fmt.Errorf("save convert price %s %s: %w", code, p.EffectiveDate, err) + } + } + return tx.Commit() +} + +// GetConvertPrices 返回指定转债的转股价历史(按生效日期升序)。 +func (s *Store) GetConvertPrices(ctx context.Context, code string) ([]model.ConvertPrice, error) { + rows, err := s.db.QueryContext(ctx, "SELECT effective_date, price FROM convert_price_history WHERE code = ? ORDER BY effective_date", code) + if err != nil { + return nil, fmt.Errorf("get convert prices: %w", err) + } + defer rows.Close() + + var prices []model.ConvertPrice + for rows.Next() { + var p model.ConvertPrice + if err := rows.Scan(&p.EffectiveDate, &p.Price); err != nil { + return nil, fmt.Errorf("scan convert price: %w", err) + } + p.Code = code + prices = append(prices, p) + } + return prices, rows.Err() +} + +// UpdatePremiumBatch 批量更新指定转债每日的转股价/转股价值/溢价率。 +func (s *Store) UpdatePremiumBatch(ctx context.Context, code string, rowsData []PremiumRow) error { + if len(rowsData) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + stmt, err := tx.PrepareContext(ctx, updatePremiumSQL) + if err != nil { + return fmt.Errorf("prepare update premium: %w", err) + } + defer stmt.Close() + + for _, r := range rowsData { + if _, err := stmt.ExecContext(ctx, r.ConvertPrice, r.ConvertValue, r.PremiumRate, code, r.Date); err != nil { + return fmt.Errorf("update premium %s %s: %w", code, r.Date, err) + } + } + return tx.Commit() +} + +// SaveStockFundamental 保存单条正股基本面数据。 +func (s *Store) SaveStockFundamental(ctx context.Context, f model.StockFundamental) error { + _, err := s.db.ExecContext(ctx, saveFundamentalSQL, + f.Code, f.Name, f.ROE, f.DebtRatio, f.NetProfit, f.Status, f.TotalAsset, f.TotalDebt, f.UpdateDate) + return err +} + +// GetStockFundamental 获取指定正股的基本面数据。 +func (s *Store) GetStockFundamental(ctx context.Context, code string) (*model.StockFundamental, error) { + row := s.db.QueryRowContext(ctx, getFundamentalSQL, code) + var f model.StockFundamental + if err := row.Scan(&f.Code, &f.Name, &f.ROE, &f.DebtRatio, &f.NetProfit, &f.Status, &f.TotalAsset, &f.TotalDebt, &f.UpdateDate); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get fundamental %s: %w", code, err) + } + return &f, nil +} + +// LoadAllFundamentals 加载全部正股基本面数据。 +func (s *Store) LoadAllFundamentals(ctx context.Context) (map[string]*model.StockFundamental, error) { + rows, err := s.db.QueryContext(ctx, allFundamentalSQL) + if err != nil { + return nil, fmt.Errorf("load all fundamentals: %w", err) + } + defer rows.Close() + + out := make(map[string]*model.StockFundamental) + for rows.Next() { + var f model.StockFundamental + if err := rows.Scan(&f.Code, &f.Name, &f.ROE, &f.DebtRatio, &f.NetProfit, &f.Status, &f.TotalAsset, &f.TotalDebt, &f.UpdateDate); err != nil { + return nil, fmt.Errorf("scan fundamental: %w", err) + } + out[f.Code] = &f + } + return out, rows.Err() +} + +// SaveUser 保存新注册用户。 +func (s *Store) SaveUser(ctx context.Context, u model.User) (int64, error) { + res, err := s.db.ExecContext(ctx, + `INSERT INTO user (username, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + u.Username, u.Email, u.PasswordHash, u.CreatedAt, u.UpdatedAt) + if err != nil { + return 0, fmt.Errorf("save user: %w", err) + } + return res.LastInsertId() +} + +// GetUserByID 按 ID 查询用户。 +func (s *Store) GetUserByID(ctx context.Context, id int64) (*model.User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, email, password_hash, created_at, updated_at FROM user WHERE id = ?`, id) + var u model.User + if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get user by id: %w", err) + } + return &u, nil +} + +// GetUserByEmail 按邮箱查询用户。 +func (s *Store) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, email, password_hash, created_at, updated_at FROM user WHERE email = ?`, email) + var u model.User + if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get user: %w", err) + } + return &u, nil +} + +// GetUserByUsername 按用户名查询用户。 +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*model.User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, email, password_hash, created_at, updated_at FROM user WHERE username = ?`, username) + var u model.User + if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get user by username: %w", err) + } + return &u, nil +} + +// SaveVerifyCode 保存验证码。 +func (s *Store) SaveVerifyCode(ctx context.Context, v model.VerifyCode) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO verify_code (email, code, purpose, expires_at, used) VALUES (?, ?, ?, ?, 0)`, + v.Email, v.Code, v.Purpose, v.ExpiresAt) + if err != nil { + return fmt.Errorf("save verify code: %w", err) + } + return nil +} + +// GetLatestVerifyCode 获取某邮箱某用途最新未使用验证码。 +func (s *Store) GetLatestVerifyCode(ctx context.Context, email, purpose string) (*model.VerifyCode, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, email, code, purpose, expires_at, used FROM verify_code + WHERE email = ? AND purpose = ? AND used = 0 + ORDER BY id DESC LIMIT 1`, email, purpose) + var v model.VerifyCode + var used int + var id int64 + if err := row.Scan(&id, &v.Email, &v.Code, &v.Purpose, &v.ExpiresAt, &used); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get verify code: %w", err) + } + v.Used = used != 0 + return &v, nil +} + +// MarkVerifyCodeUsed 将验证码标记为已使用。 +func (s *Store) MarkVerifyCodeUsed(ctx context.Context, email, code, purpose string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE verify_code SET used = 1 WHERE email = ? AND code = ? AND purpose = ?`, + email, code, purpose) + if err != nil { + return fmt.Errorf("mark verify code used: %w", err) + } + return nil +} + +// GetStockBondMap 返回正股代码到转债代码的映射。 +func (s *Store) GetStockBondMap(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT stock_code, code FROM convert_bond WHERE stock_code != ''") + if err != nil { + return nil, fmt.Errorf("get stock bond map: %w", err) + } + defer rows.Close() + + m := make(map[string]string) + for rows.Next() { + var stock, bond string + if err := rows.Scan(&stock, &bond); err != nil { + return nil, fmt.Errorf("scan stock bond: %w", err) + } + if m[stock] == "" { + m[stock] = bond + } + } + return m, rows.Err() +} + +// Close 关闭数据库连接。 +func (s *Store) Close() error { + return s.db.Close() +} + +// 编译期断言:确保 Store 实现 storage.KlineStore。 +var _ storage.KlineStore = (*Store)(nil) diff --git a/internal/storage/sqlite/store_test.go b/internal/storage/sqlite/store_test.go new file mode 100644 index 0000000..7ae5512 --- /dev/null +++ b/internal/storage/sqlite/store_test.go @@ -0,0 +1,287 @@ +package sqlite + +import ( + "context" + "os" + "path/filepath" + "testing" + + "stock-analysis/internal/model" +) + +func tempDB(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "test.db") +} + +func TestOpenAndClose(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } +} + +func TestSaveAndGetKlines(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + klines := []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99, Volume: 1000, Amount: 100000, ConvertPrice: 10, ConvertValue: 1010, PremiumRate: 5}, + {Date: "2020-01-03", Open: 101, Close: 102, High: 103, Low: 100, Volume: 2000, Amount: 200000, ConvertPrice: 10, ConvertValue: 1020, PremiumRate: 4}, + {Date: "2020-01-06", Open: 102, Close: 100, High: 104, Low: 99, Volume: 1500, Amount: 150000, ConvertPrice: 10, ConvertValue: 1000, PremiumRate: 6}, + } + + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := store.GetKlines(ctx, "113001") + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 klines, got %d", len(got)) + } + + // 验证按日期升序 + if got[0].Date != "2020-01-02" { + t.Errorf("first date: expected 2020-01-02, got %s", got[0].Date) + } + if got[2].Date != "2020-01-06" { + t.Errorf("last date: expected 2020-01-06, got %s", got[2].Date) + } + if got[1].Close != 102 { + t.Errorf("second close: expected 102, got %f", got[1].Close) + } +} + +func TestSaveKlines_Idempotent(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + klines := []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99, Volume: 1000}, + } + + // 第一次写入 + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("first save: %v", err) + } + + // 第二次写入(幂等) + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("second save: %v", err) + } + + got, err := store.GetKlines(ctx, "113001") + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 kline, got %d", len(got)) + } +} + +func TestCountKlines(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // 空库 + n, err := store.CountKlines(ctx, "113001") + if err != nil { + t.Fatalf("count empty: %v", err) + } + if n != 0 { + t.Errorf("expected 0, got %d", n) + } + + klines := []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99}, + {Date: "2020-01-03", Open: 101, Close: 102, High: 103, Low: 100}, + } + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("save: %v", err) + } + + n, err = store.CountKlines(ctx, "113001") + if err != nil { + t.Fatalf("count: %v", err) + } + if n != 2 { + t.Errorf("expected 2, got %d", n) + } +} + +func TestListCodes(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + + klines := []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99}, + } + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("save 113001: %v", err) + } + if err := store.SaveKlines(ctx, "113002", klines); err != nil { + t.Fatalf("save 113002: %v", err) + } + + codes, err := store.ListCodes(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(codes) != 2 { + t.Fatalf("expected 2 codes, got %d", len(codes)) + } + if codes[0] != "113001" || codes[1] != "113002" { + t.Errorf("expected [113001, 113002], got %v", codes) + } +} + +func TestLoadAllKlines(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + + if err := store.SaveKlines(ctx, "A", []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99}, + {Date: "2020-01-03", Open: 101, Close: 102, High: 103, Low: 100}, + }); err != nil { + t.Fatalf("save A: %v", err) + } + if err := store.SaveKlines(ctx, "B", []model.Kline{ + {Date: "2020-01-02", Open: 200, Close: 201, High: 202, Low: 199}, + }); err != nil { + t.Fatalf("save B: %v", err) + } + + all, err := store.LoadAllKlines(ctx) + if err != nil { + t.Fatalf("load all: %v", err) + } + if len(all) != 2 { + t.Fatalf("expected 2 codes, got %d", len(all)) + } + if len(all["A"]) != 2 { + t.Errorf("expected 2 klines for A, got %d", len(all["A"])) + } + if len(all["B"]) != 1 { + t.Errorf("expected 1 kline for B, got %d", len(all["B"])) + } +} + +func TestOpen_NonExistentDir(t *testing.T) { + path := filepath.Join(t.TempDir(), "nonexistent", "test.db") + // 确保父目录存在 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + store.Close() +} + +func TestSaveBonds(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + bonds := []model.ConvertibleBond{ + {Code: "113001", Name: "测试转债1", StockCode: "600001", InitialConvertPrice: 10, ListDate: "2020-01-01"}, + {Code: "113002", Name: "测试转债2", StockCode: "600002", InitialConvertPrice: 15, ListDate: "2020-06-01"}, + } + + if err := store.SaveBonds(ctx, bonds); err != nil { + t.Fatalf("save bonds: %v", err) + } + + got, err := store.ListBonds(ctx) + if err != nil { + t.Fatalf("list bonds: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 bonds, got %d", len(got)) + } + if got[0].Name != "测试转债1" { + t.Errorf("first bond name: expected 测试转债1, got %s", got[0].Name) + } +} + +func TestUpdatePremiumBatch(t *testing.T) { + path := tempDB(t) + store, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // 先写入K线 + klines := []model.Kline{ + {Date: "2020-01-02", Open: 100, Close: 101, High: 102, Low: 99}, + } + if err := store.SaveKlines(ctx, "113001", klines); err != nil { + t.Fatalf("save: %v", err) + } + + // 更新溢价率 + rows := []PremiumRow{ + {Date: "2020-01-02", ConvertPrice: 10, ConvertValue: 1010, PremiumRate: 5.5}, + } + if err := store.UpdatePremiumBatch(ctx, "113001", rows); err != nil { + t.Fatalf("update premium: %v", err) + } + + // 验证 + got, err := store.GetKlines(ctx, "113001") + if err != nil { + t.Fatalf("get: %v", err) + } + if got[0].ConvertPrice != 10 { + t.Errorf("convert price: expected 10, got %f", got[0].ConvertPrice) + } + if got[0].ConvertValue != 1010 { + t.Errorf("convert value: expected 1010, got %f", got[0].ConvertValue) + } + if got[0].PremiumRate != 5.5 { + t.Errorf("premium rate: expected 5.5, got %f", got[0].PremiumRate) + } +} \ No newline at end of file diff --git a/internal/strategy/doublelow.go b/internal/strategy/doublelow.go new file mode 100644 index 0000000..5ca1d53 --- /dev/null +++ b/internal/strategy/doublelow.go @@ -0,0 +1,87 @@ +// Package strategy 提供内置回测策略(MVC Service 层)。 +// 用户可在 backtest.Strategy 接口基础上扩展自定义轮动/网格等策略。 +package strategy + +import ( + "context" + "fmt" + "sort" + + "stock-analysis/internal/backtest" +) + +// DoubleLowParams 双低轮动策略参数。 +type DoubleLowParams struct { + TopN int // 持仓数量 + Rebalance int // 调仓周期(交易日) + MinPrice float64 // 价格下限(过滤异常低价/退市风险债) + MaxPrice float64 // 价格上限 + MaxPremium float64 // 溢价率上限(过滤高溢价债,0 表示不限制) +} + +// DefaultDoubleLowParams 返回推荐默认参数。 +func DefaultDoubleLowParams() DoubleLowParams { + return DoubleLowParams{TopN: 10, Rebalance: 21, MinPrice: 90, MaxPrice: 130, MaxPremium: 30} +} + +// DoubleLow 双低轮动策略:每个调仓日选择"双低值 = 转债价 + 转股溢价率(%)" +// 最低的 TopN 只转债等权持有。该策略兼顾"低价"的债性保护与"低溢价"的股性弹性, +// 是经典的稳健型可转债轮动框架。 +type DoubleLow struct { + params DoubleLowParams + day int // 已执行的交易日计数 +} + +// NewDoubleLow 创建双低轮动策略。 +func NewDoubleLow(p DoubleLowParams) *DoubleLow { + return &DoubleLow{params: p} +} + +// Name 返回策略名称。 +func (s *DoubleLow) Name() string { + return fmt.Sprintf("双低轮动(top%d,每%d交易日)", s.params.TopN, s.params.Rebalance) +} + +// OnBar 实现 backtest.Strategy:交易日递增,到调仓日时按双低值选股返回目标持仓。 +func (s *DoubleLow) OnBar(ctx context.Context, date string, universe []backtest.Quote, hist *backtest.History) ([]backtest.Target, error) { + s.day++ + if s.day > 1 && (s.day-1)%s.params.Rebalance != 0 { + return nil, nil // 非调仓日 + } + + type cand struct { + code string + doubleLow float64 + } + candidates := make([]cand, 0, len(universe)) + for _, q := range universe { + if q.Price < s.params.MinPrice || q.Price > s.params.MaxPrice { + continue + } + // 溢价率缺失(未入库)或非正时为 0/负值,不参与双低排序。 + if q.PremiumRate <= 0 { + continue + } + if s.params.MaxPremium > 0 && q.PremiumRate > s.params.MaxPremium { + continue + } + candidates = append(candidates, cand{ + code: q.Code, + doubleLow: q.Price + q.PremiumRate, + }) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].doubleLow < candidates[j].doubleLow }) + if len(candidates) > s.params.TopN { + candidates = candidates[:s.params.TopN] + } + if len(candidates) == 0 { + return nil, nil // 无合格标的,保持现状 + } + + weight := 1.0 / float64(len(candidates)) + targets := make([]backtest.Target, 0, len(candidates)) + for _, c := range candidates { + targets = append(targets, backtest.Target{Code: c.code, Weight: weight}) + } + return targets, nil +} diff --git a/internal/strategy/doublelow_test.go b/internal/strategy/doublelow_test.go new file mode 100644 index 0000000..ed6f4b4 --- /dev/null +++ b/internal/strategy/doublelow_test.go @@ -0,0 +1,152 @@ +package strategy + +import ( + "context" + "testing" + + "stock-analysis/internal/backtest" +) + +func TestDoubleLow_Name(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 10, Rebalance: 21, MinPrice: 90, MaxPrice: 130, MaxPremium: 30}) + name := s.Name() + if name == "" { + t.Error("strategy name should not be empty") + } +} + +func TestDoubleLow_SelectByDoubleLowValue(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 3, Rebalance: 5, MinPrice: 90, MaxPrice: 130, MaxPremium: 30}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100, PremiumRate: 10}, // 双低 = 110 + {Code: "B", Price: 95, PremiumRate: 5}, // 双低 = 100 (最低) + {Code: "C", Price: 110, PremiumRate: 20}, // 双低 = 130 + {Code: "D", Price: 105, PremiumRate: 8}, // 双低 = 113 + {Code: "E", Price: 120, PremiumRate: 15}, // 双低 = 135 + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 3 { + t.Fatalf("expected 3 targets, got %d", len(targets)) + } + + // 按双低值排序:B(100), A(110), D(113) + codes := make(map[string]bool) + for _, tg := range targets { + codes[tg.Code] = true + } + if !codes["B"] || !codes["A"] || !codes["D"] { + t.Errorf("expected codes B, A, D, got %v", codes) + } +} + +func TestDoubleLow_FilterByPriceRange(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 5, Rebalance: 5, MinPrice: 90, MaxPrice: 130, MaxPremium: 30}) + + universe := []backtest.Quote{ + {Code: "A", Price: 85, PremiumRate: 5}, // 价格过低,过滤 + {Code: "B", Price: 100, PremiumRate: 10}, // 合格 + {Code: "C", Price: 135, PremiumRate: 5}, // 价格过高,过滤 + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } + if targets[0].Code != "B" { + t.Errorf("expected code B, got %s", targets[0].Code) + } +} + +func TestDoubleLow_FilterByPremiumRate(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 5, Rebalance: 5, MinPrice: 90, MaxPrice: 130, MaxPremium: 20}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100, PremiumRate: 15}, // 合格 + {Code: "B", Price: 100, PremiumRate: 25}, // 溢价过高,过滤 + {Code: "C", Price: 100, PremiumRate: 0}, // 溢价为0,过滤 + {Code: "D", Price: 100, PremiumRate: -5}, // 负溢价,过滤 + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } + if targets[0].Code != "A" { + t.Errorf("expected code A, got %s", targets[0].Code) + } +} + +func TestDoubleLow_NoQualifiedCandidates(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 3, Rebalance: 5, MinPrice: 90, MaxPrice: 130, MaxPremium: 30}) + + universe := []backtest.Quote{ + {Code: "A", Price: 50, PremiumRate: 5}, + {Code: "B", Price: 200, PremiumRate: 10}, + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if targets != nil { + t.Error("expected nil targets when no qualified candidates") + } +} + +func TestDoubleLow_RebalanceCycle(t *testing.T) { + s := NewDoubleLow(DoubleLowParams{TopN: 3, Rebalance: 3, MinPrice: 90, MaxPrice: 130, MaxPremium: 30}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100, PremiumRate: 10}, + } + + // 第 1 天:调仓 + targets, _ := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets == nil { + t.Error("day 1 should rebalance") + } + + // 第 2-3 天:不调仓 + for i := 0; i < 2; i++ { + targets, _ = s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets != nil { + t.Errorf("day %d should not rebalance", i+2) + } + } + + // 第 4 天:调仓 + targets, _ = s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets == nil { + t.Error("day 4 should rebalance") + } +} + +func TestDefaultDoubleLowParams(t *testing.T) { + p := DefaultDoubleLowParams() + if p.TopN != 10 { + t.Errorf("TopN: expected 10, got %d", p.TopN) + } + if p.Rebalance != 21 { + t.Errorf("Rebalance: expected 21, got %d", p.Rebalance) + } + if p.MinPrice != 90 { + t.Errorf("MinPrice: expected 90, got %f", p.MinPrice) + } + if p.MaxPrice != 130 { + t.Errorf("MaxPrice: expected 130, got %f", p.MaxPrice) + } + if p.MaxPremium != 30 { + t.Errorf("MaxPremium: expected 30, got %f", p.MaxPremium) + } +} \ No newline at end of file diff --git a/internal/strategy/lowprice.go b/internal/strategy/lowprice.go new file mode 100644 index 0000000..1ada7bc --- /dev/null +++ b/internal/strategy/lowprice.go @@ -0,0 +1,71 @@ +// Package strategy 提供内置回测策略(MVC Service 层)。 +// 用户可在 backtest.Strategy 接口基础上扩展自定义轮动/网格等策略。 +package strategy + +import ( + "context" + "fmt" + "sort" + + "stock-analysis/internal/backtest" +) + +// LowPriceParams 低价轮动策略参数。 +type LowPriceParams struct { + TopN int // 持仓数量 + Rebalance int // 调仓周期(交易日) + MinPrice float64 // 价格下限(过滤异常低价/退市风险债) + MaxPrice float64 // 价格上限(低价策略通常低于 130) +} + +// DefaultLowPriceParams 返回推荐默认参数。 +func DefaultLowPriceParams() LowPriceParams { + return LowPriceParams{TopN: 10, Rebalance: 21, MinPrice: 80, MaxPrice: 130} +} + +// LowPrice 低价轮动策略:每 Rebalance 个交易日,选择全市场价格最低的 TopN +// 只转债等权持有。基于可转债"下有保底"特性,低价格通常意味着较低的下行风险, +// 是经典的可转债轮动框架(演示用,可替换为双低/溢价等因子)。 +type LowPrice struct { + params LowPriceParams + day int // 已执行的交易日计数 +} + +// NewLowPrice 创建低价轮动策略。 +func NewLowPrice(p LowPriceParams) *LowPrice { + return &LowPrice{params: p} +} + +// Name 返回策略名称。 +func (s *LowPrice) Name() string { + return fmt.Sprintf("低价轮动(top%d,每%d交易日)", s.params.TopN, s.params.Rebalance) +} + +// OnBar 实现 backtest.Strategy:交易日递增,到调仓日时选股返回目标持仓。 +func (s *LowPrice) OnBar(ctx context.Context, date string, universe []backtest.Quote, hist *backtest.History) ([]backtest.Target, error) { + s.day++ + if s.day > 1 && (s.day-1)%s.params.Rebalance != 0 { + return nil, nil // 非调仓日 + } + + candidates := make([]backtest.Quote, 0, len(universe)) + for _, q := range universe { + if q.Price >= s.params.MinPrice && q.Price <= s.params.MaxPrice { + candidates = append(candidates, q) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Price < candidates[j].Price }) + if len(candidates) > s.params.TopN { + candidates = candidates[:s.params.TopN] + } + if len(candidates) == 0 { + return nil, nil // 无合格标的,保持现状 + } + + weight := 1.0 / float64(len(candidates)) + targets := make([]backtest.Target, 0, len(candidates)) + for _, q := range candidates { + targets = append(targets, backtest.Target{Code: q.Code, Weight: weight}) + } + return targets, nil +} diff --git a/internal/strategy/lowprice_test.go b/internal/strategy/lowprice_test.go new file mode 100644 index 0000000..bd02ed5 --- /dev/null +++ b/internal/strategy/lowprice_test.go @@ -0,0 +1,139 @@ +package strategy + +import ( + "context" + "testing" + + "stock-analysis/internal/backtest" +) + +func TestLowPrice_Name(t *testing.T) { + s := NewLowPrice(LowPriceParams{TopN: 10, Rebalance: 21, MinPrice: 80, MaxPrice: 130}) + name := s.Name() + if name == "" { + t.Error("strategy name should not be empty") + } +} + +func TestLowPrice_FirstDaySelectsTopN(t *testing.T) { + s := NewLowPrice(LowPriceParams{TopN: 3, Rebalance: 5, MinPrice: 80, MaxPrice: 130}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100}, + {Code: "B", Price: 90}, + {Code: "C", Price: 110}, + {Code: "D", Price: 85}, + {Code: "E", Price: 120}, + {Code: "F", Price: 75}, // 低于 MinPrice,应被过滤 + {Code: "G", Price: 135}, // 高于 MaxPrice,应被过滤 + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 3 { + t.Fatalf("expected 3 targets, got %d", len(targets)) + } + + // 按价格升序,应选 D(85), B(90), A(100) + codes := make(map[string]bool) + for _, tg := range targets { + codes[tg.Code] = true + } + if !codes["D"] || !codes["B"] || !codes["A"] { + t.Errorf("expected codes D, B, A, got %v", codes) + } + + // 权重应为等权 1/3 + for _, tg := range targets { + if tg.Weight != 1.0/3.0 { + t.Errorf("weight for %s: expected %f, got %f", tg.Code, 1.0/3.0, tg.Weight) + } + } +} + +func TestLowPrice_NonRebalanceDay(t *testing.T) { + s := NewLowPrice(LowPriceParams{TopN: 3, Rebalance: 5, MinPrice: 80, MaxPrice: 130}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100}, + } + + // 第 1 天:调仓 + targets, _ := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets == nil { + t.Error("day 1 should rebalance") + } + + // 第 2-5 天:不调仓 + for i := 0; i < 4; i++ { + targets, _ = s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets != nil { + t.Errorf("day %d should not rebalance", i+2) + } + } + + // 第 6 天:调仓 + targets, _ = s.OnBar(context.Background(), "2020-01-02", universe, nil) + if targets == nil { + t.Error("day 6 should rebalance") + } +} + +func TestLowPrice_NoQualifiedCandidates(t *testing.T) { + s := NewLowPrice(LowPriceParams{TopN: 3, Rebalance: 5, MinPrice: 80, MaxPrice: 130}) + + // 所有标的价格都不在范围内 + universe := []backtest.Quote{ + {Code: "A", Price: 50}, + {Code: "B", Price: 200}, + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if targets != nil { + t.Error("expected nil targets when no qualified candidates") + } +} + +func TestLowPrice_FewerThanTopN(t *testing.T) { + s := NewLowPrice(LowPriceParams{TopN: 10, Rebalance: 5, MinPrice: 80, MaxPrice: 130}) + + universe := []backtest.Quote{ + {Code: "A", Price: 100}, + {Code: "B", Price: 110}, + } + + targets, err := s.OnBar(context.Background(), "2020-01-02", universe, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(targets)) + } + // 权重应为等权 1/2 + for _, tg := range targets { + if tg.Weight != 0.5 { + t.Errorf("weight for %s: expected 0.5, got %f", tg.Code, tg.Weight) + } + } +} + +func TestDefaultLowPriceParams(t *testing.T) { + p := DefaultLowPriceParams() + if p.TopN != 10 { + t.Errorf("TopN: expected 10, got %d", p.TopN) + } + if p.Rebalance != 21 { + t.Errorf("Rebalance: expected 21, got %d", p.Rebalance) + } + if p.MinPrice != 80 { + t.Errorf("MinPrice: expected 80, got %f", p.MinPrice) + } + if p.MaxPrice != 130 { + t.Errorf("MaxPrice: expected 130, got %f", p.MaxPrice) + } +} \ No newline at end of file diff --git a/internal/strategy/premium.go b/internal/strategy/premium.go new file mode 100644 index 0000000..6deed34 --- /dev/null +++ b/internal/strategy/premium.go @@ -0,0 +1,79 @@ +package strategy + +import ( + "context" + "fmt" + "sort" + + "stock-analysis/internal/backtest" +) + +// PremiumParams 溢价率轮动策略参数。 +type PremiumParams struct { + TopN int // 持仓数量 + Rebalance int // 调仓周期(交易日) + MinPrice float64 // 价格下限 + MaxPrice float64 // 价格上限 +} + +// DefaultPremiumParams 返回推荐默认参数(价格放宽到 90-150)。 +func DefaultPremiumParams() PremiumParams { + return PremiumParams{TopN: 10, Rebalance: 21, MinPrice: 90, MaxPrice: 150} +} + +// Premium 溢价率轮动策略:每 Rebalance 个交易日,选择转股溢价率最低的 TopN +// 只转债等权持有。溢价率低意味着转债价格与正股联动紧密,正股涨则转债跟涨, +// 适合正股上涨行情,避免"正股涨转债不涨"的尴尬。 +type Premium struct { + params PremiumParams + day int +} + +// NewPremium 创建溢价率轮动策略。 +func NewPremium(p PremiumParams) *Premium { + return &Premium{params: p} +} + +// Name 返回策略名称。 +func (s *Premium) Name() string { + return fmt.Sprintf("溢价率轮动(top%d,每%d交易日)", s.params.TopN, s.params.Rebalance) +} + +// OnBar 实现 backtest.Strategy:到调仓日时按溢价率升序选股。 +// 注意:东财历史K线API不返回转股溢价率,当前数据库中的 PremiumRate 均为 0。 +// 因此策略暂以"价格"作为溢价率的代理排序指标(低价债通常溢价率更低), +// 待后续接入正股K线数据后将改为按实际溢价率排序。 +func (s *Premium) OnBar(ctx context.Context, date string, universe []backtest.Quote, hist *backtest.History) ([]backtest.Target, error) { + s.day++ + if s.day > 1 && (s.day-1)%s.params.Rebalance != 0 { + return nil, nil + } + + candidates := make([]backtest.Quote, 0, len(universe)) + for _, q := range universe { + if q.Price >= s.params.MinPrice && q.Price <= s.params.MaxPrice { + candidates = append(candidates, q) + } + } + // 按溢价率排序:有实际溢价率数据时优先使用,否则回退到价格排序。 + sort.Slice(candidates, func(i, j int) bool { + pi, pj := candidates[i].PremiumRate, candidates[j].PremiumRate + if pi > 0 && pj > 0 { + return pi < pj + } + return candidates[i].Price < candidates[j].Price + }) + if len(candidates) > s.params.TopN { + candidates = candidates[:s.params.TopN] + } + if len(candidates) == 0 { + return nil, nil + } + + weight := 1.0 / float64(len(candidates)) + targets := make([]backtest.Target, 0, len(candidates)) + for _, q := range candidates { + targets = append(targets, backtest.Target{Code: q.Code, Weight: weight}) + } + return targets, nil +} \ No newline at end of file diff --git a/web/auth.html b/web/auth.html new file mode 100644 index 0000000..6937985 --- /dev/null +++ b/web/auth.html @@ -0,0 +1,626 @@ + + + + + + 登录 / 注册 - 可转债轮动分析系统 + + + + +
+
+
+
+ +

可转债轮动分析系统

+

登录 / 注册

+
+ +
+
登录
+
注册
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + +
+ +
+ 登录即表示同意 服务条款 与 隐私政策 +
+
+
+ + + + \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2d6d0ec --- /dev/null +++ b/web/index.html @@ -0,0 +1,963 @@ + + + + + + 可转债轮动分析系统 + + + + +
+
+

可转债轮动分析系统

+
实时行情 · 策略回测 · 数据管理
+
+ +
+ +
+
实时行情
+
策略回测
+
数据管理
+
+ + +
+
+
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + +
代码名称价格涨跌%溢价率%转股价值转股价纯债价值正股正股涨跌%
+
+
+ + +
+
+
+ 策略参数 + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+ 转债选择 + +
+
+
+ + + + +
+
+ + + +
代码名称K线数
+
+
+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..1114ee6 --- /dev/null +++ b/web/style.css @@ -0,0 +1,320 @@ +:root { + --navy: #0D1B2A; + --navy-light: #1B2A4A; + --blue: #1E5FBB; + --blue-light: #E8F0FE; + --green: #007A5E; + --green-light: #E6F4F0; + --red: #C8102E; + --red-light: #FDE8EC; + --gray-50: #F5F6FA; + --gray-100: #E8EAF0; + --gray-200: #D1D5DE; + --gray-500: #7C8499; + --gray-700: #4A5168; + --gray-900: #1A1F2E; + --white: #FFFFFF; + --shadow-sm: 0 1px 2px rgba(13,27,42,0.06); + --shadow-md: 0 2px 8px rgba(13,27,42,0.08); + --shadow-lg: 0 4px 16px rgba(13,27,42,0.10); + --radius: 8px; + --radius-sm: 4px; + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif; + background: var(--gray-50); + color: var(--gray-900); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* Auth page */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(135deg, var(--navy) 0%, var(--navy-light) 100%); +} + +.auth-card { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 420px; + padding: 32px; +} + +.auth-header { + text-align: center; + margin-bottom: 24px; +} +.auth-header h2 { + font-size: 22px; + font-weight: 600; + color: var(--navy); + margin-bottom: 6px; +} +.auth-header p { + font-size: 13px; + color: var(--gray-500); +} + +.auth-tabs { + display: flex; + border-bottom: 1px solid var(--gray-100); + margin-bottom: 24px; +} +.auth-tab { + flex: 1; + text-align: center; + padding: 12px 0; + cursor: pointer; + color: var(--gray-500); + font-weight: 500; + position: relative; +} +.auth-tab.active { + color: var(--blue); + font-weight: 600; +} +.auth-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 25%; + width: 50%; + height: 2px; + background: var(--blue); + border-radius: 2px 2px 0 0; +} + +.auth-form .form-item { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 14px; +} +.auth-form label { + font-size: 12px; + color: var(--gray-500); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.4px; +} +.auth-form input { + padding: 10px 12px; + border: 1px solid var(--gray-200); + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--gray-900); + transition: border-color var(--transition); +} +.auth-form input:focus { + border-color: var(--blue); + outline: none; + box-shadow: 0 0 0 3px var(--blue-light); +} + +.code-input-group { + display: flex; + gap: 8px; +} +.code-input-group input { + flex: 1; +} +.code-input-group button { + white-space: nowrap; +} + +.auth-hint { + text-align: center; + font-size: 12px; + color: var(--gray-500); + margin-top: 16px; +} +.auth-hint a { + color: var(--blue); + text-decoration: none; +} + +button { + padding: 8px 20px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all var(--transition); + letter-spacing: 0.3px; +} +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.btn-primary { + background: var(--blue); + color: var(--white); + box-shadow: 0 1px 3px rgba(30,95,187,0.3); +} +.btn-primary:hover:not(:disabled) { + background: #1A52A3; + box-shadow: 0 2px 6px rgba(30,95,187,0.4); +} +.btn-default { + background: var(--white); + border: 1px solid var(--gray-200); + color: var(--gray-700); +} +.btn-default:hover:not(:disabled) { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-light); +} + +.error { + color: var(--red); + background: var(--red-light); + border: 1px solid #F5A3B0; + padding: 10px 12px; + border-radius: var(--radius-sm); + margin-bottom: 14px; + font-size: 13px; +} + +/* Header user */ +.header-user { + display: flex; + align-items: center; + gap: 10px; +} +.header-user .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--blue), #1A52A3); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--white); + flex-shrink: 0; +} +.header-user .user-name { + font-size: 13px; + opacity: 0.9; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.header-user a { + color: var(--white); + font-size: 13px; + text-decoration: none; + opacity: 0.75; + padding: 4px 10px; + border-radius: 4px; + transition: all var(--transition); +} +.header-user a:hover { + opacity: 1; + background: rgba(255,255,255,0.1); +} + +/* Toast notification */ +.toast { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + padding: 12px 20px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + box-shadow: 0 4px 20px rgba(13,27,42,0.15); + animation: toastIn 0.3s ease-out; + max-width: 360px; + display: flex; + align-items: center; + gap: 8px; +} +.toast.success { background: var(--green); color: var(--white); } +.toast.error { background: var(--red); color: var(--white); } +.toast.info { background: var(--navy); color: var(--white); } +@keyframes toastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(20px); } +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 48px 20px; + color: var(--gray-500); +} +.empty-state .empty-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.4; +} +.empty-state .empty-text { + font-size: 14px; + margin-bottom: 4px; +} +.empty-state .empty-hint { + font-size: 12px; + opacity: 0.7; +} + +/* Table row animation */ +@keyframes rowIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.table-wrap tbody tr { + animation: rowIn 0.2s ease-out both; +} +.table-wrap tbody tr:nth-child(1) { animation-delay: 0s; } +.table-wrap tbody tr:nth-child(2) { animation-delay: 0.02s; } +.table-wrap tbody tr:nth-child(3) { animation-delay: 0.04s; } +.table-wrap tbody tr:nth-child(4) { animation-delay: 0.06s; } +.table-wrap tbody tr:nth-child(5) { animation-delay: 0.08s; } +.table-wrap tbody tr:nth-child(6) { animation-delay: 0.10s; } +.table-wrap tbody tr:nth-child(7) { animation-delay: 0.12s; } +.table-wrap tbody tr:nth-child(8) { animation-delay: 0.14s; } +.table-wrap tbody tr:nth-child(9) { animation-delay: 0.16s; } +.table-wrap tbody tr:nth-child(10) { animation-delay: 0.18s; } + +/* Scrollbar styling */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--gray-200); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--gray-500); } + +/* Tab content transition */ +.tab-content { + animation: fadeIn 0.25s ease-out; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Responsive */ +@media (max-width: 768px) { + .header-user .user-name { max-width: 80px; } + .toast { top: 10px; right: 10px; left: 10px; max-width: none; } +}