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