From 61cc29d7b31f0ebb1eda7e8dd0f855b9592213ae Mon Sep 17 00:00:00 2001 From: yangfan <784310601@qq.com> Date: Wed, 13 May 2026 17:46:03 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=82=E9=85=8Dmysql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mysql-unified-ddl.sql | 149 ++++++++++++++++++ docs/mysql-unify-collation.sql | 19 +++ docs/second-cal.md | 5 + .../api/limit_second_profit_csv_handler.go | 46 +++--- internal/dbcompat/dbcompat.go | 147 +++++++++++++++++ internal/model/limit_board.go | 2 + ...it_board_break_next_performance_service.go | 45 +++--- .../limit_board_open_compare_service.go | 9 +- .../limit_board_open_threshold_service.go | 7 +- .../service/limit_board_overview_service.go | 3 +- internal/service/second_cal_service.go | 135 +++++++++------- internal/service/stock_service.go | 3 +- 12 files changed, 456 insertions(+), 114 deletions(-) create mode 100644 docs/mysql-unified-ddl.sql create mode 100644 docs/mysql-unify-collation.sql create mode 100644 internal/dbcompat/dbcompat.go diff --git a/docs/mysql-unified-ddl.sql b/docs/mysql-unified-ddl.sql new file mode 100644 index 0000000..39c3d86 --- /dev/null +++ b/docs/mysql-unified-ddl.sql @@ -0,0 +1,149 @@ +ALTER DATABASE `level` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `limit_list_d` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `trade_date` date NOT NULL COMMENT '交易日期', + `ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码', + `industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属行业', + `name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '股票名称', + `close` decimal(16,4) DEFAULT NULL COMMENT '收盘价', + `pct_chg` decimal(10,4) DEFAULT NULL COMMENT '涨跌幅', + `amount` decimal(24,4) DEFAULT NULL COMMENT '成交额', + `limit_amount` decimal(24,4) DEFAULT NULL COMMENT '板上成交金额', + `float_mv` decimal(24,4) DEFAULT NULL COMMENT '流通市值', + `total_mv` decimal(24,4) DEFAULT NULL COMMENT '总市值', + `turnover_ratio` decimal(10,4) DEFAULT NULL COMMENT '换手率', + `fd_amount` decimal(24,4) DEFAULT NULL COMMENT '封单金额', + `first_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '首次封板时间', + `last_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后封板时间', + `open_times` int DEFAULT NULL COMMENT '打开次数', + `up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '涨停统计', + `limit_times` int DEFAULT NULL COMMENT '连板数', + `limit` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'D跌停 U涨停 Z炸板', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_trade_code_limit` (`trade_date`,`ts_code`,`limit`), + KEY `idx_trade_date` (`trade_date`), + KEY `idx_ts_code` (`ts_code`), + KEY `idx_limit` (`limit`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `stock_basic` ( + `ts_code` text COLLATE utf8mb4_unicode_ci, + `symbol` text COLLATE utf8mb4_unicode_ci, + `name` text COLLATE utf8mb4_unicode_ci, + `area` text COLLATE utf8mb4_unicode_ci, + `industry` text COLLATE utf8mb4_unicode_ci, + `fullname` text COLLATE utf8mb4_unicode_ci, + `enname` text COLLATE utf8mb4_unicode_ci, + `cnspell` text COLLATE utf8mb4_unicode_ci, + `market` text COLLATE utf8mb4_unicode_ci, + `exchange` text COLLATE utf8mb4_unicode_ci, + `curr_type` text COLLATE utf8mb4_unicode_ci, + `list_status` text COLLATE utf8mb4_unicode_ci, + `list_date` text COLLATE utf8mb4_unicode_ci, + `delist_date` text COLLATE utf8mb4_unicode_ci, + `is_hs` text COLLATE utf8mb4_unicode_ci, + `act_name` text COLLATE utf8mb4_unicode_ci, + `act_ent_type` text COLLATE utf8mb4_unicode_ci +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `stock_daily` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL, + `trade_date` date NOT NULL, + `open` decimal(16,4) DEFAULT NULL, + `high` decimal(16,4) DEFAULT NULL, + `low` decimal(16,4) DEFAULT NULL, + `close` decimal(16,4) DEFAULT NULL, + `pre_close` decimal(16,4) DEFAULT NULL, + `change_val` decimal(16,4) DEFAULT NULL, + `pct_chg` decimal(10,4) DEFAULT NULL, + `vol` bigint DEFAULT NULL, + `amount` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_code_date` (`ts_code`,`trade_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `stock_limit_combined_data` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `trade_date` date NOT NULL COMMENT '交易日期', + `symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码,以stock_daily为准', + `ts_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'TS股票代码,以stock_daily为准', + `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票名称,以stock_daily关联基础表为准', + `final_status` tinyint NOT NULL COMMENT '融合状态:1-涨停,2-炸板', + `source_count` int NOT NULL DEFAULT '0' COMMENT '命中的数据源数量', + `source_flags` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '命中的数据源:calc,tushare', + `calc_status` tinyint DEFAULT NULL COMMENT '本地行情计算状态:1-涨停,2-炸板', + `calc_limit_price` decimal(16,4) DEFAULT NULL COMMENT '本地行情计算涨停价', + `calc_high` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily最高价', + `calc_close` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily收盘价', + `calc_pre_close` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily前收盘价', + `calc_vol` bigint DEFAULT NULL COMMENT 'stock_daily成交量', + `market` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属板块', + `tushare_limit_type` varchar(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-D跌停U涨停Z炸板', + `tushare_industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-所属行业', + `tushare_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-名称', + `tushare_close` decimal(16,4) DEFAULT NULL COMMENT 'Tushare-收盘价', + `tushare_pct_chg` decimal(10,4) DEFAULT NULL COMMENT 'Tushare-涨跌幅', + `tushare_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-成交额', + `tushare_limit_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-板上成交金额', + `tushare_float_mv` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-流通市值', + `tushare_total_mv` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-总市值', + `tushare_turnover_ratio` decimal(10,4) DEFAULT NULL COMMENT 'Tushare-换手率', + `tushare_fd_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-封单金额', + `tushare_first_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-首次封板时间', + `tushare_last_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-最后封板时间', + `tushare_open_times` int DEFAULT NULL COMMENT 'Tushare-炸板/开板次数', + `tushare_up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-涨停统计', + `tushare_limit_times` int DEFAULT NULL COMMENT 'Tushare-连板数', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`), + KEY `idx_trade_date` (`trade_date`), + KEY `idx_symbol` (`symbol`), + KEY `idx_trade_date_status` (`trade_date`,`final_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `trade_calendar` ( + `cal_date` date NOT NULL, + `exchange` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL, + `is_open` tinyint DEFAULT NULL, + `pretrade_date` date DEFAULT NULL, + PRIMARY KEY (`cal_date`,`exchange`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +CREATE TABLE `stock_limit_data` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `trade_date` date NOT NULL COMMENT '交易日期', + `symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码', + `ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'TS股票代码', + `name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票名称', + `status` tinyint NOT NULL COMMENT '状态:1-涨停,2-炸板', + `limit_price` decimal(16,4) DEFAULT NULL COMMENT '计算出的理论涨停价', + `close` decimal(16,4) DEFAULT NULL COMMENT '实际收盘价', + `vol` bigint DEFAULT NULL COMMENT '成交量', + `market` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属板块', + `ak_code` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-代码', + `ak_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-名称', + `ak_pct_chg` decimal(10,4) DEFAULT NULL COMMENT 'AKShare-涨跌幅', + `ak_latest_price` decimal(16,4) DEFAULT NULL COMMENT 'AKShare-最新价', + `ak_amount` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-成交额', + `ak_float_market_value` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-流通市值', + `ak_total_market_value` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-总市值', + `ak_turnover_rate` decimal(10,4) DEFAULT NULL COMMENT 'AKShare-换手率', + `ak_seal_amount` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-封板资金', + `ak_first_limit_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-首次封板时间', + `ak_last_limit_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-最后封板时间', + `ak_break_limit_count` int DEFAULT NULL COMMENT 'AKShare-炸板次数', + `ak_limit_up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-涨停统计', + `ak_limit_up_days` int DEFAULT NULL COMMENT 'AKShare-连板数', + `ak_industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-所属行业', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/docs/mysql-unify-collation.sql b/docs/mysql-unify-collation.sql new file mode 100644 index 0000000..23b3946 --- /dev/null +++ b/docs/mysql-unify-collation.sql @@ -0,0 +1,19 @@ +ALTER DATABASE `level` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `limit_list_d` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `stock_basic` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `stock_daily` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `stock_limit_combined_data` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `trade_calendar` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `stock_limit_data` + CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/docs/second-cal.md b/docs/second-cal.md index aab9e71..c335315 100644 --- a/docs/second-cal.md +++ b/docs/second-cal.md @@ -32,6 +32,7 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0 - 涨停价比例按板块计算:北交所 30%,创业板/科创板 20%,其余名称包含 ST 的股票 5%,其他股票 10%。 - T 日 `close` 与 `high` 一致时,计为二板封板,`status = "limit"`。 - T 日 `close` 小于 `high` 时,计为二板炸板,`status = "break"`。 +- `first_limit_time` 和 `open_times` 来自 T 日二板记录,封板取 `limit_list_d.limit = 'U'` 记录,炸板取 `limit_list_d.limit = 'Z'` 记录;时间格式化为 `HH:MM:SS`。 - T+1 盘中盈利统一按 `T+1 high > T high` 判断,概率分母只统计具备 T 日最高价和 T+1 最高价的数据。 ## 4. 响应字段 @@ -75,6 +76,8 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0 | `t_high` / `t_close` | `number \| null` | T 日最高价和收盘价 | | `t_pct_chg` | `number \| null` | T 日收盘涨跌幅 | | `status` | `string` | `limit` 表示封板,`break` 表示炸板 | +| `first_limit_time` | `string` | T 日二板首次涨停时间,格式化为 `HH:MM:SS`;无数据时为空字符串 | +| `open_times` | `int \| null` | T 日二板开板次数 | | `next_high` / `next_close` | `number \| null` | T+1 最高价和收盘价 | | `next_intraday_profitable` | `boolean \| null` | T+1 盘中是否盈利 | | `next_high_profit_pct` | `number \| null` | T+1 最高价相对 T 日最高价的收益率 | @@ -135,6 +138,8 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0 "t_close": 11, "t_pct_chg": 10, "status": "limit", + "first_limit_time": "09:35:12", + "open_times": 0, "next_high": 11.2, "next_close": 11.1, "next_intraday_profitable": true, diff --git a/internal/api/limit_second_profit_csv_handler.go b/internal/api/limit_second_profit_csv_handler.go index b146b48..133f11d 100644 --- a/internal/api/limit_second_profit_csv_handler.go +++ b/internal/api/limit_second_profit_csv_handler.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "level_go/internal/dbcompat" ) const ( @@ -336,21 +337,21 @@ func validateSecondProfitDateRange(startDate, endDate string) error { // SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情; // Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。 func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) { + seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(h.Service.DB, req.TradeStartDate, req.TradeEndDate) + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return []secondProfitCSVRow{}, nil + } + calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds) + query := ` -WITH open_calendar AS ( - SELECT - DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date, - LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS previous_trade_date, - LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, - LEAD(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS next_trade_date - FROM trade_calendar - WHERE exchange = 'SSE' AND is_open = 1 -) SELECT - c.trade_date, - c.previous_trade_date, - c.previous_two_trade_date, - COALESCE(c.next_trade_date, '') AS next_trade_date, + DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date, + DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date, + DATE_FORMAT(c.previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date, + COALESCE(DATE_FORMAT(c.next_trade_date, '%Y-%m-%d'), '') AS next_trade_date, l.ts_code, COALESCE(l.symbol, '') AS symbol, COALESCE(l.name, '') AS name, @@ -373,29 +374,28 @@ SELECT nd.high AS next_high, nd.low AS next_low, nd.close AS next_close -FROM open_calendar c +FROM (` + calendarTable + `) c INNER JOIN stock_limit_combined_data l ON l.trade_date = c.trade_date AND l.final_status = 1 INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1 LEFT JOIN stock_limit_combined_data ppl - ON ppl.ts_code = l.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1 LEFT JOIN stock_daily d - ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date LEFT JOIN stock_daily pd - ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = c.previous_trade_date LEFT JOIN stock_daily ppd - ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "ppd.ts_code") + ` AND ppd.trade_date = c.previous_two_trade_date LEFT JOIN stock_daily nd - ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date -WHERE c.trade_date BETWEEN ? AND ? - AND c.previous_trade_date IS NOT NULL + ON ` + dbcompat.TsCodeEqual("l.ts_code", "nd.ts_code") + ` AND nd.trade_date = c.next_trade_date +WHERE c.previous_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL AND ppl.ts_code IS NULL ORDER BY c.trade_date ASC, l.ts_code ASC` var rawRows []secondProfitCSVRow - if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil { + if err := h.Service.DB.Raw(query, calendarArgs...).Scan(&rawRows).Error; err != nil { return nil, err } diff --git a/internal/dbcompat/dbcompat.go b/internal/dbcompat/dbcompat.go new file mode 100644 index 0000000..31d7a1f --- /dev/null +++ b/internal/dbcompat/dbcompat.go @@ -0,0 +1,147 @@ +package dbcompat + +import ( + "strings" + + "gorm.io/gorm" +) + +// CalendarSeed is a MySQL-version-neutral replacement for LAG/LEAD over the +// trade calendar. Keeping the adjacent dates in Go avoids depending on MySQL 8. +type CalendarSeed struct { + TradeDate string + PreviousTradeDate string + PreviousTwoTradeDate string + NextTradeDate string +} + +type calendarDateRow struct { + TradeDate string `gorm:"column:trade_date"` +} + +type columnCollationRow struct { + CollationName string `gorm:"column:collation_name"` +} + +// LoadSSEOpenCalendarSeeds loads all SSE open dates and builds the date window +// needed by range queries. The returned seeds are limited to [startDate, endDate]. +func LoadSSEOpenCalendarSeeds(db *gorm.DB, startDate, endDate string) ([]CalendarSeed, error) { + var rows []calendarDateRow + if err := db.Table("trade_calendar"). + Select("DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date"). + Where("exchange = ? AND is_open = ?", "SSE", 1). + Order("cal_date ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + seeds := make([]CalendarSeed, 0) + for i, row := range rows { + if row.TradeDate < startDate || row.TradeDate > endDate { + continue + } + + seed := CalendarSeed{TradeDate: row.TradeDate} + if i > 0 { + seed.PreviousTradeDate = rows[i-1].TradeDate + } + if i > 1 { + seed.PreviousTwoTradeDate = rows[i-2].TradeDate + } + if i+1 < len(rows) { + seed.NextTradeDate = rows[i+1].TradeDate + } + seeds = append(seeds, seed) + } + + return seeds, nil +} + +// CalendarSeedTable converts calendar seeds into a derived table that works on +// MySQL 5.7, MySQL 8, and MariaDB without CTE/window-function support. +func CalendarSeedTable(seeds []CalendarSeed) (string, []any) { + if len(seeds) == 0 { + return `SELECT + CAST(NULL AS DATE) AS trade_date, + CAST(NULL AS DATE) AS previous_trade_date, + CAST(NULL AS DATE) AS previous_two_trade_date, + CAST(NULL AS DATE) AS next_trade_date +WHERE 1 = 0`, nil + } + + parts := make([]string, 0, len(seeds)) + args := make([]any, 0, len(seeds)*4) + for _, seed := range seeds { + parts = append(parts, `SELECT + CAST(? AS DATE) AS trade_date, + CAST(? AS DATE) AS previous_trade_date, + CAST(? AS DATE) AS previous_two_trade_date, + CAST(? AS DATE) AS next_trade_date`) + args = append(args, + nullableDate(seed.TradeDate), + nullableDate(seed.PreviousTradeDate), + nullableDate(seed.PreviousTwoTradeDate), + nullableDate(seed.NextTradeDate), + ) + } + + return strings.Join(parts, "\nUNION ALL\n"), args +} + +// TsCodeEqual keeps joins free of hard-coded COLLATE clauses. This avoids +// binding SQL to MySQL-8-only collations while preserving index-friendly joins. +func TsCodeEqual(left, right string) string { + return left + " = " + right +} + +// ColumnCollation returns the current database collation for one column. +func ColumnCollation(db *gorm.DB, tableName, columnName string) (string, error) { + var row columnCollationRow + err := db.Raw(` +SELECT COLLATION_NAME AS collation_name +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? +LIMIT 1`, tableName, columnName).Scan(&row).Error + if err != nil { + return "", err + } + return row.CollationName, nil +} + +// TsCodeEqualUnicode compares a utf8mb4_unicode_ci column with a column that +// may be utf8mb4_0900_ai_ci. utf8mb4_unicode_ci exists on both MySQL 5.7 and 8. +func TsCodeEqualUnicode(left, right string) string { + return left + " = " + right + " COLLATE utf8mb4_unicode_ci" +} + +// TsCodeEqualRightCollation compares two stock-code columns using a collation +// known to exist on the connected database. This avoids MySQL-version-specific +// literals while preserving the fast side of joins such as stock_daily.ts_code. +func TsCodeEqualRightCollation(left, right, collation string) string { + if !safeCollationName(collation) { + return TsCodeEqual(left, right) + } + return left + " = " + right + " COLLATE " + collation +} + +func safeCollationName(collation string) bool { + if collation == "" { + return false + } + for _, r := range collation { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + continue + } + return false + } + return true +} + +func nullableDate(date string) any { + if strings.TrimSpace(date) == "" { + return nil + } + return date +} diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index 77ceace..46a1766 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -157,6 +157,8 @@ type SecondCalStock struct { TClose *float64 `json:"t_close"` // T 日收盘价。 TPctChg *float64 `json:"t_pct_chg"` // T 日收盘涨跌幅。 Status string `json:"status"` // T 日二板状态:limit 表示封板,break 表示炸板。 + FirstLimitTime string `json:"first_limit_time"` // T 日二板首次涨停时间,格式化为 HH:MM:SS。 + OpenTimes *int `json:"open_times"` // T 日二板开板次数。 NextHigh *float64 `json:"next_high"` // T+1 日最高价。 NextClose *float64 `json:"next_close"` // T+1 日收盘价。 NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 盘中最高价是否高于 T 日最高价。 diff --git a/internal/service/limit_board_break_next_performance_service.go b/internal/service/limit_board_break_next_performance_service.go index e3cb27a..3387f54 100644 --- a/internal/service/limit_board_break_next_performance_service.go +++ b/internal/service/limit_board_break_next_performance_service.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -104,17 +105,18 @@ func (s *LimitBoardService) ListLimitBreakNextPerformanceStocks(req model.LimitB // // 口径:前一交易日涨停、前两交易日未涨停、当前交易日 final_status = 2。 func (s *LimitBoardService) loadLimitBreakNextPerformanceRows(startDate, endDate string) ([]limitBreakNextPerformanceRow, error) { + seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate) + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return []limitBreakNextPerformanceRow{}, nil + } + calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds) + query := ` -WITH open_calendar AS ( - SELECT - cal_date AS trade_date, - LAG(cal_date, 1) OVER (ORDER BY cal_date) AS previous_trade_date, - LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, - LEAD(cal_date, 1) OVER (ORDER BY cal_date) AS next_trade_date - FROM trade_calendar - WHERE exchange = 'SSE' AND is_open = 1 -), -candidate AS ( +SELECT * +FROM ( SELECT DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date, DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date, @@ -141,34 +143,31 @@ candidate AS ( nd.high AS next_high, nd.low AS next_low, nd.close AS next_close - FROM open_calendar c + FROM (` + calendarTable + `) c INNER JOIN stock_limit_combined_data l ON l.trade_date = c.trade_date AND l.final_status = 2 INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1 LEFT JOIN stock_limit_combined_data ppl - ON ppl.ts_code = l.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1 LEFT JOIN stock_daily d - ON d.ts_code = l.ts_code AND d.trade_date = c.trade_date + ON ` + dbcompat.TsCodeEqual("d.ts_code", "l.ts_code") + ` AND d.trade_date = c.trade_date LEFT JOIN stock_daily pd - ON pd.ts_code = l.ts_code AND pd.trade_date = c.previous_trade_date + ON ` + dbcompat.TsCodeEqual("pd.ts_code", "l.ts_code") + ` AND pd.trade_date = c.previous_trade_date LEFT JOIN stock_daily nd - ON nd.ts_code = l.ts_code AND nd.trade_date = c.next_trade_date + ON ` + dbcompat.TsCodeEqual("nd.ts_code", "l.ts_code") + ` AND nd.trade_date = c.next_trade_date LEFT JOIN stock_basic sb - ON sb.ts_code = l.ts_code COLLATE utf8mb4_unicode_ci - WHERE c.trade_date BETWEEN ? AND ? - AND c.previous_trade_date IS NOT NULL + ON ` + dbcompat.TsCodeEqualUnicode("sb.ts_code", "l.ts_code") + ` + WHERE c.previous_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL AND ppl.ts_code IS NULL -) -SELECT * -FROM candidate +) candidate WHERE previous_close IS NOT NULL AND t_close IS NOT NULL ORDER BY trade_date ASC, ts_code ASC` var rows []limitBreakNextPerformanceRow - err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error + err = s.DB.Raw(query, calendarArgs...).Scan(&rows).Error return rows, err } diff --git a/internal/service/limit_board_open_compare_service.go b/internal/service/limit_board_open_compare_service.go index 3ad9458..d2aae6b 100644 --- a/internal/service/limit_board_open_compare_service.go +++ b/internal/service/limit_board_open_compare_service.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -146,7 +147,7 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD (? <> '' AND NOT EXISTS ( SELECT 1 FROM stock_limit_combined_data ppl - WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.final_status = 1 + WHERE ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = ? AND ppl.final_status = 1 )) AS is_first_to_second_board, COALESCE(l.calc_limit_price, 0) AS limit_price, COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, @@ -164,11 +165,11 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD l.calc_vol AS vol FROM stock_limit_combined_data l LEFT JOIN stock_daily d - ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date LEFT JOIN stock_daily pd - ON l.ts_code = pd.ts_code AND pd.trade_date = ? + ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = ? INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = ? AND pl.final_status = 1 WHERE l.trade_date = ? AND l.final_status = 1 ORDER BY d.pct_chg DESC, l.ts_code ASC` diff --git a/internal/service/limit_board_open_threshold_service.go b/internal/service/limit_board_open_threshold_service.go index 2bbe44e..3a8438f 100644 --- a/internal/service/limit_board_open_threshold_service.go +++ b/internal/service/limit_board_open_threshold_service.go @@ -3,6 +3,7 @@ package service import ( "sort" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -93,11 +94,11 @@ func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTrad l.calc_vol AS vol FROM stock_limit_combined_data l LEFT JOIN stock_daily d - ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date LEFT JOIN stock_daily pd - ON l.ts_code = pd.ts_code AND pd.trade_date = ? + ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = ? INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 + ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = ? AND pl.final_status = 1 WHERE l.trade_date = ? AND l.final_status = 1 ORDER BY d.pct_chg DESC, l.ts_code ASC` diff --git a/internal/service/limit_board_overview_service.go b/internal/service/limit_board_overview_service.go index abd481e..51ee2a5 100644 --- a/internal/service/limit_board_overview_service.go +++ b/internal/service/limit_board_overview_service.go @@ -3,6 +3,7 @@ package service import ( "sort" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -146,7 +147,7 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li l.calc_vol AS vol FROM stock_limit_combined_data l LEFT JOIN stock_daily d - ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date + ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date WHERE l.trade_date = ? AND l.final_status = 1 ORDER BY d.pct_chg DESC, l.ts_code ASC` diff --git a/internal/service/second_cal_service.go b/internal/service/second_cal_service.go index 25acbd1..780c7b4 100644 --- a/internal/service/second_cal_service.go +++ b/internal/service/second_cal_service.go @@ -3,6 +3,7 @@ package service import ( "math" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -23,6 +24,8 @@ type secondCalRow struct { TClose *float64 `gorm:"column:t_close"` // T 日收盘价。 TPctChg *float64 `gorm:"column:t_pct_chg"` // T 日收盘涨跌幅。 Status string `gorm:"column:status"` // T 日二板状态。 + FirstLimitTime *string `gorm:"column:first_limit_time"` // T 日首次涨停时间。 + OpenTimes *int `gorm:"column:open_times"` // T 日开板次数。 NextHigh *float64 `gorm:"column:next_high"` // T+1 日最高价。 NextClose *float64 `gorm:"column:next_close"` // T+1 日收盘价。 } @@ -85,29 +88,19 @@ func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.Seco // loadSecondCalDays 查询日期范围内的开市交易日和相邻交易日。 func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]secondCalDaySeed, map[string]int, error) { - query := ` -WITH open_calendar AS ( - SELECT - cal_date AS trade_date, - LAG(cal_date, 1) OVER (ORDER BY cal_date) AS previous_trade_date, - LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, - LEAD(cal_date, 1) OVER (ORDER BY cal_date) AS next_trade_date - FROM trade_calendar - WHERE exchange = 'SSE' AND is_open = 1 -) -SELECT - DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, - COALESCE(DATE_FORMAT(previous_trade_date, '%Y-%m-%d'), '') AS previous_trade_date, - COALESCE(DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d'), '') AS previous_two_trade_date, - COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date -FROM open_calendar -WHERE trade_date BETWEEN ? AND ? -ORDER BY trade_date ASC` - - var days []secondCalDaySeed - if err := s.DB.Raw(query, startDate, endDate).Scan(&days).Error; err != nil { + seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate) + if err != nil { return nil, nil, err } + days := make([]secondCalDaySeed, 0, len(seeds)) + for _, seed := range seeds { + days = append(days, secondCalDaySeed{ + TradeDate: seed.TradeDate, + PreviousTradeDate: seed.PreviousTradeDate, + PreviousTwoTradeDate: seed.PreviousTwoTradeDate, + NextTradeDate: seed.NextTradeDate, + }) + } // 构建交易日到响应数组下标的映射,后续聚合明细时可以 O(1) 定位。 dayIndex := make(map[string]int, len(days)) @@ -119,17 +112,41 @@ ORDER BY trade_date ASC` // loadSecondCalRows 查询 T-1 涨停、T-2 未涨停、T 日再次触及涨停价的二板股票。 func (s *LimitBoardService) loadSecondCalRows(startDate, endDate string) ([]secondCalRow, error) { + seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate) + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return []secondCalRow{}, nil + } + calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds) + stockDailyTsCodeCollation, err := dbcompat.ColumnCollation(s.DB, "stock_daily", "ts_code") + if err != nil { + return nil, err + } + query := ` -WITH open_calendar AS ( - SELECT - cal_date AS trade_date, - LAG(cal_date, 1) OVER (ORDER BY cal_date) AS previous_trade_date, - LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, - LEAD(cal_date, 1) OVER (ORDER BY cal_date) AS next_trade_date - FROM trade_calendar - WHERE exchange = 'SSE' AND is_open = 1 -), -candidate AS ( +SELECT + DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, + DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date, + DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date, + COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date, + COALESCE(symbol, '') AS symbol, + ts_code, + name, + market, + industry, + previous_close, + limit_price, + t_high, + t_close, + t_pct_chg, + status, + first_limit_time, + open_times, + next_high, + next_close +FROM ( SELECT c.trade_date, c.previous_trade_date, @@ -159,45 +176,41 @@ candidate AS ( WHEN d.close < d.high THEN 'break' ELSE 'unknown' END AS status, + CASE + WHEN ABS(d.close - d.high) < 0.0001 + THEN NULLIF(NULLIF(TRIM(tlu.first_time), ''), 'None') + WHEN d.close < d.high + THEN NULLIF(NULLIF(TRIM(tlz.first_time), ''), 'None') + ELSE NULL + END AS first_limit_time, + CASE + WHEN ABS(d.close - d.high) < 0.0001 THEN tlu.open_times + WHEN d.close < d.high THEN tlz.open_times + ELSE NULL + END AS open_times, nd.high AS next_high, nd.close AS next_close - FROM open_calendar c + FROM (` + calendarTable + `) c INNER JOIN limit_list_d pl ON pl.trade_date = c.previous_trade_date AND pl.` + "`limit`" + ` = 'U' LEFT JOIN limit_list_d ppl - ON ppl.ts_code = pl.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U' + ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "pl.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U' INNER JOIN stock_daily d - ON d.ts_code = pl.ts_code COLLATE utf8mb4_0900_ai_ci AND d.trade_date = c.trade_date + ON ` + dbcompat.TsCodeEqualRightCollation("d.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND d.trade_date = c.trade_date LEFT JOIN stock_daily nd - ON nd.ts_code = pl.ts_code COLLATE utf8mb4_0900_ai_ci AND nd.trade_date = c.next_trade_date + ON ` + dbcompat.TsCodeEqualRightCollation("nd.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND nd.trade_date = c.next_trade_date + LEFT JOIN limit_list_d tlu + ON ` + dbcompat.TsCodeEqual("tlu.ts_code", "pl.ts_code") + ` AND tlu.trade_date = c.trade_date AND tlu.` + "`limit`" + ` = 'U' + LEFT JOIN limit_list_d tlz + ON ` + dbcompat.TsCodeEqual("tlz.ts_code", "pl.ts_code") + ` AND tlz.trade_date = c.trade_date AND tlz.` + "`limit`" + ` = 'Z' LEFT JOIN stock_basic sb - ON sb.ts_code = pl.ts_code COLLATE utf8mb4_unicode_ci - WHERE c.trade_date BETWEEN ? AND ? - AND c.previous_trade_date IS NOT NULL + ON ` + dbcompat.TsCodeEqual("sb.ts_code", "pl.ts_code") + ` + WHERE c.previous_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL AND ppl.ts_code IS NULL AND d.high IS NOT NULL AND d.close IS NOT NULL -) -SELECT - DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, - DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date, - DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date, - COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date, - COALESCE(symbol, '') AS symbol, - ts_code, - name, - market, - industry, - previous_close, - limit_price, - t_high, - t_close, - t_pct_chg, - status, - next_high, - next_close -FROM candidate +) candidate WHERE previous_close IS NOT NULL AND limit_price IS NOT NULL AND limit_price > 0 @@ -206,7 +219,7 @@ WHERE previous_close IS NOT NULL ORDER BY trade_date ASC, ts_code ASC` var rows []secondCalRow - err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error + err = s.DB.Raw(query, calendarArgs...).Scan(&rows).Error return rows, err } @@ -228,9 +241,13 @@ func buildSecondCalStock(row secondCalRow) model.SecondCalStock { TClose: row.TClose, TPctChg: row.TPctChg, Status: row.Status, + OpenTimes: row.OpenTimes, NextHigh: row.NextHigh, NextClose: row.NextClose, } + if row.FirstLimitTime != nil { + item.FirstLimitTime = formatLimitTime(*row.FirstLimitTime) + } if row.THigh != nil && *row.THigh > 0 && row.NextHigh != nil { // T+1 盘中盈利统一按 T+1 最高价是否高于 T 日最高价判断。 diff --git a/internal/service/stock_service.go b/internal/service/stock_service.go index 5d1d69e..4ac6ad9 100644 --- a/internal/service/stock_service.go +++ b/internal/service/stock_service.go @@ -5,6 +5,7 @@ import ( "sort" "gorm.io/gorm" + "level_go/internal/dbcompat" "level_go/internal/model" ) @@ -45,7 +46,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An // 查询 startDate 当日涨停股,并优先使用涨停合并表中的计算最高价。 query := `SELECT l.ts_code, l.name, d.open, COALESCE(l.calc_high, d.high, 0) AS high FROM stock_limit_combined_data l - LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date + LEFT JOIN stock_daily d ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND l.trade_date = d.trade_date WHERE l.trade_date = ? AND l.final_status = 1` if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil {