Compare commits

..

14 Commits

Author SHA1 Message Date
yang de5da361e8 新增获取历史二板数据脚本 2026-05-17 13:19:57 +08:00
yang 115c663055 日常日期变更 2026-05-13 17:46:49 +08:00
yang b95b0cd188 删除 2026-05-13 09:53:18 +08:00
yang 06430ef801 sql 2026-05-12 22:55:00 +08:00
yang 76f2a41b3c 日常查询日期变更 2026-05-12 17:23:16 +08:00
yang 28627caeb1 日常查询日期变更 2026-05-09 17:00:17 +08:00
yang ea6dd0c9fc 新增涨跌停炸板单独表脚本 2026-05-09 00:24:59 +08:00
yang 3141d998d2 日常查询日期变更 2026-05-07 17:27:49 +08:00
yang a914866c62 代码优化 2026-05-06 18:10:33 +08:00
yang 4f68ab8710 数据日期变更 2026-05-05 20:00:05 +08:00
yang ea3d969056 修改涨停板逻辑 2026-05-05 19:05:47 +08:00
yang f8395f706e 修改limit_up数据来源 2026-05-05 14:13:11 +08:00
yang 7b8a47ffe6 新增列数据 2026-04-30 17:13:43 +08:00
yang a2b80e9d85 新增 akshare 逻辑(从 2026-04-01开始) 2026-04-30 00:07:48 +08:00
7 changed files with 1527 additions and 66 deletions
+2 -2
View File
@@ -130,8 +130,8 @@ def run(start_date, end_date, token):
# ======================= # =======================
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="同步交易日历") parser = argparse.ArgumentParser(description="同步交易日历")
parser.add_argument("--start-date", default="20260429", help="开始日期,格式 YYYYMMDD") parser.add_argument("--start-date", default="20260513", help="开始日期,格式 YYYYMMDD")
parser.add_argument("--end-date", default="20260429", help="结束日期,格式 YYYYMMDD") parser.add_argument("--end-date", default="20260515", help="结束日期,格式 YYYYMMDD")
args = parser.parse_args() args = parser.parse_args()
run(args.start_date, args.end_date, TUSHARE_TOKEN) run(args.start_date, args.end_date, TUSHARE_TOKEN)
+20 -11
View File
@@ -1,5 +1,6 @@
import tushare as ts import tushare as ts
import pymysql import pymysql
import pandas as pd
from datetime import datetime from datetime import datetime
import time import time
import argparse import argparse
@@ -53,6 +54,14 @@ def str_to_date(date_str):
return datetime.strptime(date_str, "%Y%m%d").date() return datetime.strptime(date_str, "%Y%m%d").date()
def mysql_value(value):
if pd.isna(value):
return None
if hasattr(value, "item"):
return value.item()
return value
# ===== 获取交易日 ===== # ===== 获取交易日 =====
def get_trade_dates(start_date, end_date): def get_trade_dates(start_date, end_date):
_, db_cursor = get_connection() _, db_cursor = get_connection()
@@ -90,15 +99,15 @@ def insert_data(df):
ts_code, ts_code,
symbol, symbol,
str_to_date(row['trade_date']), # 保持 DATE 类型 str_to_date(row['trade_date']), # 保持 DATE 类型
row['open'], mysql_value(row['open']),
row['high'], mysql_value(row['high']),
row['low'], mysql_value(row['low']),
row['close'], mysql_value(row['close']),
row['pre_close'], mysql_value(row['pre_close']),
row['change'], mysql_value(row['change']),
row['pct_chg'], mysql_value(row['pct_chg']),
row['vol'], mysql_value(row['vol']),
row['amount'] mysql_value(row['amount'])
)) ))
db_cursor.executemany(sql, data) db_cursor.executemany(sql, data)
@@ -148,8 +157,8 @@ def run(start_date, end_date):
# ===== 输入参数 ===== # ===== 输入参数 =====
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="同步日行情") parser = argparse.ArgumentParser(description="同步日行情")
parser.add_argument("--start-date", default="20260429", help="开始日期,格式 YYYYMMDD") parser.add_argument("--start-date", default="20260513", help="开始日期,格式 YYYYMMDD")
parser.add_argument("--end-date", default="20260429", help="结束日期,格式 YYYYMMDD") parser.add_argument("--end-date", default="20260515", help="结束日期,格式 YYYYMMDD")
args = parser.parse_args() args = parser.parse_args()
run(args.start_date, args.end_date) run(args.start_date, args.end_date)
+283
View File
@@ -0,0 +1,283 @@
import time
from collections import deque
from datetime import date, datetime
import pandas as pd
import tushare as ts
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from stock_config import TUSHARE_TOKEN, get_engine
TABLE_NAME = "limit_list_d"
LIMIT_TYPES = ("U", "D", "Z")
TUSHARE_START_DATE = date(2020, 1, 1)
MAX_CALLS_PER_MINUTE = 200
FIELDS = (
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
"open_times,up_stat,limit_times,limit"
)
class MinuteRateLimiter:
def __init__(self, max_calls):
self.max_calls = max_calls
self.call_times = deque()
def wait(self):
now = time.monotonic()
while self.call_times and now - self.call_times[0] >= 60:
self.call_times.popleft()
if len(self.call_times) >= self.max_calls:
sleep_seconds = 60 - (now - self.call_times[0]) + 0.1
print(f"⏳ Tushare 限流等待 {sleep_seconds:.1f} 秒。")
time.sleep(sleep_seconds)
now = time.monotonic()
while self.call_times and now - self.call_times[0] >= 60:
self.call_times.popleft()
self.call_times.append(time.monotonic())
def parse_date(value):
value = str(value).strip()
for fmt in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.strptime(value, fmt).date()
except ValueError:
pass
raise ValueError(f"日期格式错误: {value},应为 YYYYMMDD 或 YYYY-MM-DD")
def to_tushare_date(value):
return value.strftime("%Y%m%d")
def init_db(engine):
sql = f"""
CREATE TABLE IF NOT EXISTS `{TABLE_NAME}` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`trade_date` DATE NOT NULL COMMENT '交易日期',
`ts_code` VARCHAR(20) NOT NULL COMMENT '股票代码',
`industry` VARCHAR(100) COMMENT '所属行业',
`name` VARCHAR(50) COMMENT '股票名称',
`close` DECIMAL(16, 4) COMMENT '收盘价',
`pct_chg` DECIMAL(10, 4) COMMENT '涨跌幅',
`amount` DECIMAL(24, 4) COMMENT '成交额',
`limit_amount` DECIMAL(24, 4) COMMENT '板上成交金额',
`float_mv` DECIMAL(24, 4) COMMENT '流通市值',
`total_mv` DECIMAL(24, 4) COMMENT '总市值',
`turnover_ratio` DECIMAL(10, 4) COMMENT '换手率',
`fd_amount` DECIMAL(24, 4) COMMENT '封单金额',
`first_time` VARCHAR(20) COMMENT '首次封板时间',
`last_time` VARCHAR(20) COMMENT '最后封板时间',
`open_times` INT COMMENT '打开次数',
`up_stat` VARCHAR(50) COMMENT '涨停统计',
`limit_times` INT COMMENT '连板数',
`limit` VARCHAR(1) NOT NULL COMMENT 'D跌停 U涨停 Z炸板',
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;
"""
with engine.begin() as conn:
conn.execute(text(sql))
print(f"✅ 表 {TABLE_NAME} 检查/创建完成。")
def get_trade_dates(engine, start_date, end_date):
try:
query = text(
"""
SELECT cal_date
FROM trade_calendar
WHERE cal_date BETWEEN :start_date AND :end_date
AND is_open = 1
ORDER BY cal_date
"""
)
df = pd.read_sql(
query,
engine,
params={"start_date": start_date, "end_date": end_date},
)
if not df.empty:
return [pd.to_datetime(value).date() for value in df["cal_date"]]
except SQLAlchemyError as exc:
print(f"⚠️ 读取 trade_calendar 失败,将按自然日尝试: {exc}")
return [value.date() for value in pd.date_range(start_date, end_date, freq="D")]
def clean_text(value):
if pd.isna(value):
return None
return str(value).strip()
def normalize_data(df):
columns = [
"trade_date",
"ts_code",
"industry",
"name",
"close",
"pct_chg",
"amount",
"limit_amount",
"float_mv",
"total_mv",
"turnover_ratio",
"fd_amount",
"first_time",
"last_time",
"open_times",
"up_stat",
"limit_times",
"limit",
]
for column in columns:
if column not in df.columns:
df[column] = None
result = df[columns].copy()
result["trade_date"] = pd.to_datetime(
result["trade_date"], format="%Y%m%d", errors="coerce"
).dt.date
for column in [
"close",
"pct_chg",
"amount",
"limit_amount",
"float_mv",
"total_mv",
"turnover_ratio",
"fd_amount",
"open_times",
"limit_times",
]:
result[column] = pd.to_numeric(result[column], errors="coerce")
for column in [
"ts_code",
"industry",
"name",
"first_time",
"last_time",
"up_stat",
"limit",
]:
result[column] = result[column].map(clean_text)
result = result.dropna(subset=["trade_date", "ts_code", "limit"])
return result.drop_duplicates(
subset=["trade_date", "ts_code", "limit"],
keep="first",
)
def fetch_limit_list_d(trade_dates):
if not trade_dates:
return pd.DataFrame()
ts.set_token(TUSHARE_TOKEN)
pro = ts.pro_api()
frames = []
rate_limiter = MinuteRateLimiter(MAX_CALLS_PER_MINUTE)
total_calls = len(trade_dates) * len(LIMIT_TYPES)
current_call = 0
print(
f"🔎 开始获取 Tushare limit_list_d:{len(trade_dates)} 个交易日,"
f"{len(LIMIT_TYPES)} 类 limit_type,共 {total_calls} 次调用。"
)
for trade_date in trade_dates:
tushare_date = to_tushare_date(trade_date)
for limit_type in LIMIT_TYPES:
current_call += 1
print(f" [{current_call}/{total_calls}] {tushare_date} limit_type={limit_type}")
rate_limiter.wait()
try:
df = pro.limit_list_d(
trade_date=tushare_date,
limit_type=limit_type,
fields=FIELDS,
)
except Exception as exc:
print(f"⚠️ 获取失败: {tushare_date} {limit_type}, 错误: {exc}")
continue
if df is None or df.empty:
continue
frames.append(normalize_data(df))
if not frames:
return pd.DataFrame()
return pd.concat(frames, ignore_index=True).drop_duplicates(
subset=["trade_date", "ts_code", "limit"],
keep="first",
)
def save_data(engine, df, trade_dates):
if not trade_dates:
return 0
with engine.begin() as conn:
delete_params = {f"date_{idx}": value for idx, value in enumerate(trade_dates)}
placeholders = ", ".join(f":date_{idx}" for idx in range(len(trade_dates)))
delete_sql = text(f"DELETE FROM `{TABLE_NAME}` WHERE trade_date IN ({placeholders})")
delete_result = conn.execute(delete_sql, delete_params)
print(f"🧹 已清理旧数据 {delete_result.rowcount} 条。")
if df.empty:
print("ℹ️ 本次没有可写入的 limit_list_d 数据。")
return 0
df.to_sql(
TABLE_NAME,
conn,
if_exists="append",
index=False,
chunksize=1000,
method="multi",
)
return len(df)
def run(start_date, end_date):
start = parse_date(start_date)
end = parse_date(end_date)
if start > end:
raise ValueError("开始日期不能大于结束日期")
if end < TUSHARE_START_DATE:
print(f"ℹ️ limit_list_d 接口从 {TUSHARE_START_DATE} 开始处理,当前区间无需同步。")
return True
if start < TUSHARE_START_DATE:
print(f"ℹ️ start_date 早于 {TUSHARE_START_DATE},已自动调整。")
start = TUSHARE_START_DATE
engine = get_engine()
init_db(engine)
trade_dates = get_trade_dates(engine, start, end)
print(f"📅 需要处理 {len(trade_dates)} 个交易日/日期:{start} ~ {end}")
if not trade_dates:
return True
df = fetch_limit_list_d(trade_dates)
inserted = save_data(engine, df, trade_dates)
print(f"✅ {TABLE_NAME} 同步完成,写入 {inserted} 条。")
return True
if __name__ == "__main__":
START_DATE = "20260513" # 开始日期,格式 YYYYMMDD 或 YYYY-MM-DD
END_DATE = "20260515" # 结束日期,格式 YYYYMMDD 或 YYYY-MM-DD
run(START_DATE, END_DATE)
+727 -49
View File
@@ -1,36 +1,654 @@
import pandas as pd import pandas as pd
from sqlalchemy import text from sqlalchemy import text
import argparse import argparse
import tushare as ts
from datetime import date
from collections import deque
import time
from stock_config import get_engine from stock_config import TUSHARE_TOKEN, get_engine
# --- 数据库配置 --- # --- 数据库配置 ---
# 请根据实际情况修改数据库连接信息 # 请根据实际情况修改数据库连接信息
# DB_CONN_STR = 'mysql+pymysql://root:password@127.0.0.1:3306/stock_data' # DB_CONN_STR = 'mysql+pymysql://root:password@127.0.0.1:3306/stock_data'
LOCAL_START_DATE = date(2018, 1, 1)
TUSHARE_LIMIT_LIST_START_DATE = date(2020, 1, 1)
TUSHARE_LIMIT_LIST_MAX_CALLS_PER_MINUTE = 200
COMBINED_TABLE = "stock_limit_combined_data"
def init_db(engine): class MinuteRateLimiter:
def __init__(self, max_calls):
self.max_calls = max_calls
self.call_times = deque()
def wait(self):
now = time.monotonic()
while self.call_times and now - self.call_times[0] >= 60:
self.call_times.popleft()
if len(self.call_times) >= self.max_calls:
sleep_seconds = 60 - (now - self.call_times[0]) + 0.1
print(f"⏳ Tushare 限流等待 {sleep_seconds:.1f} 秒,避免超过每分钟 {self.max_calls} 次。")
time.sleep(sleep_seconds)
now = time.monotonic()
while self.call_times and now - self.call_times[0] >= 60:
self.call_times.popleft()
self.call_times.append(time.monotonic())
def normalize_trade_dates(trade_dates):
return sorted({pd.to_datetime(trade_date).date() for trade_date in trade_dates})
def filter_dates_by_start(trade_dates, start_date, source_name):
normalized_dates = normalize_trade_dates(trade_dates)
filtered_dates = [trade_date for trade_date in normalized_dates if trade_date >= start_date]
skipped_count = len(normalized_dates) - len(filtered_dates)
if skipped_count:
print(f"ℹ️ {source_name} 跳过 {skipped_count} 个早于 {start_date} 的交易日。")
return filtered_dates
def init_db(engine, full_maintenance=False):
""" """
初始化数据库表结构 初始化数据库表结构
1. 增加 symbol 列,位于 trade_date 之后
2. 建立 UNIQUE KEY 防止物理重复写入
"""
create_table_sql = """
CREATE TABLE IF NOT EXISTS `stock_limit_data` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`trade_date` DATE NOT NULL COMMENT '交易日期',
`symbol` VARCHAR(10) NOT NULL COMMENT '股票代码',
`ts_code` VARCHAR(20) NOT NULL COMMENT 'TS股票代码',
`name` VARCHAR(50) NOT NULL COMMENT '股票名称',
`status` TINYINT NOT NULL COMMENT '状态:1-涨停,2-炸板',
`limit_price` DECIMAL(16, 4) COMMENT '计算出的理论涨停价',
`close` DECIMAL(16, 4) COMMENT '实际收盘价',
`market` VARCHAR(20) COMMENT '所属板块',
UNIQUE KEY `uniq_date_code` (`trade_date`, `ts_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""" """
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(text(create_table_sql)) print(f" - 确保 {COMBINED_TABLE} 存在 ...")
conn.execute(text(get_combined_table_sql()))
optimize_combined_table(conn, full_maintenance=full_maintenance)
def get_combined_table_sql():
return f"""
CREATE TABLE IF NOT EXISTS `{COMBINED_TABLE}` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`trade_date` DATE NOT NULL COMMENT '交易日期',
`symbol` VARCHAR(10) NOT NULL COMMENT '股票代码,以stock_daily为准',
`ts_code` VARCHAR(20) NOT NULL COMMENT 'TS股票代码,以stock_daily为准',
`name` VARCHAR(50) 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) COMMENT '命中的数据源:calc,tushare',
`calc_status` TINYINT COMMENT '本地行情计算状态:1-涨停,2-炸板',
`calc_limit_price` DECIMAL(16, 4) COMMENT '本地行情计算涨停价',
`calc_high` DECIMAL(16, 4) COMMENT 'stock_daily最高价',
`calc_close` DECIMAL(16, 4) COMMENT 'stock_daily收盘价',
`calc_pre_close` DECIMAL(16, 4) COMMENT 'stock_daily前收盘价',
`calc_vol` BIGINT COMMENT 'stock_daily成交量',
`market` VARCHAR(20) COMMENT '所属板块',
`tushare_limit_type` VARCHAR(1) COMMENT 'Tushare-D跌停U涨停Z炸板',
`tushare_industry` VARCHAR(100) COMMENT 'Tushare-所属行业',
`tushare_name` VARCHAR(50) COMMENT 'Tushare-名称',
`tushare_close` DECIMAL(16, 4) COMMENT 'Tushare-收盘价',
`tushare_pct_chg` DECIMAL(10, 4) COMMENT 'Tushare-涨跌幅',
`tushare_amount` DECIMAL(24, 4) COMMENT 'Tushare-成交额',
`tushare_limit_amount` DECIMAL(24, 4) COMMENT 'Tushare-板上成交金额',
`tushare_float_mv` DECIMAL(24, 4) COMMENT 'Tushare-流通市值',
`tushare_total_mv` DECIMAL(24, 4) COMMENT 'Tushare-总市值',
`tushare_turnover_ratio` DECIMAL(10, 4) COMMENT 'Tushare-换手率',
`tushare_fd_amount` DECIMAL(24, 4) COMMENT 'Tushare-封单金额',
`tushare_first_time` VARCHAR(20) COMMENT 'Tushare-首次封板时间',
`tushare_last_time` VARCHAR(20) COMMENT 'Tushare-最后封板时间',
`tushare_open_times` INT COMMENT 'Tushare-炸板/开板次数',
`tushare_up_stat` VARCHAR(50) COMMENT 'Tushare-涨停统计',
`tushare_limit_times` INT COMMENT 'Tushare-连板数',
UNIQUE KEY `uniq_date_code` (`trade_date`, `ts_code`),
KEY `idx_trade_date` (`trade_date`),
KEY `idx_symbol` (`symbol`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""
def get_table_columns(conn, table_name):
return {
row[0]
for row in conn.execute(text(f"SHOW COLUMNS FROM `{table_name}`")).fetchall()
}
def get_column_collation(conn, table_name, column_name):
row = conn.execute(
text(
"""
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""
),
{"table_name": table_name, "column_name": column_name},
).first()
return row[0] if row else None
def index_exists(conn, table_name, index_name):
return conn.execute(
text(
"""
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND index_name = :index_name
LIMIT 1
"""
),
{"table_name": table_name, "index_name": index_name},
).first() is not None
def get_index_definition(conn, table_name, index_name):
rows = conn.execute(
text(
"""
SELECT column_name, non_unique
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND index_name = :index_name
ORDER BY seq_in_index
"""
),
{"table_name": table_name, "index_name": index_name},
).fetchall()
if not rows:
return None
return {
"columns": [row[0] for row in rows],
"unique": rows[0][1] == 0,
}
def index_matches(conn, table_name, index_name, columns, unique=False):
index_def = get_index_definition(conn, table_name, index_name)
if not index_def:
return False
return index_def["columns"] == columns and (not unique or index_def["unique"])
def ensure_combined_columns(conn):
existing_columns = get_table_columns(conn, COMBINED_TABLE)
columns = {
"calc_vol": "BIGINT COMMENT 'stock_daily成交量'",
}
for column_name, column_def in columns.items():
if column_name not in existing_columns:
conn.execute(
text(
f"ALTER TABLE `{COMBINED_TABLE}` "
f"ADD COLUMN `{column_name}` {column_def} AFTER `calc_pre_close`"
)
)
ensure_combined_join_collation(conn)
def ensure_combined_join_collation(conn):
daily_collation = get_column_collation(conn, "stock_daily", "ts_code")
combined_collation = get_column_collation(conn, COMBINED_TABLE, "ts_code")
if not daily_collation or not combined_collation or daily_collation == combined_collation:
return
print(f" - 对齐 ts_code collation:{combined_collation} -> {daily_collation} ...")
conn.execute(
text(
f"""
ALTER TABLE `{COMBINED_TABLE}`
MODIFY `ts_code` VARCHAR(20)
CHARACTER SET utf8mb4 COLLATE {daily_collation}
NOT NULL COMMENT 'TS股票代码,以stock_daily为准'
"""
)
)
def sync_combined_vol_from_daily(conn):
conn.execute(
text(
f"""
UPDATE `{COMBINED_TABLE}` c
JOIN `stock_daily` d
ON c.ts_code = d.ts_code
AND c.trade_date = d.trade_date
SET c.symbol = d.symbol,
c.calc_vol = d.vol
"""
)
)
def dedupe_combined_table(conn):
conn.execute(
text(
f"""
DELETE c
FROM `{COMBINED_TABLE}` c
LEFT JOIN `stock_daily` d
ON c.ts_code = d.ts_code
AND c.trade_date = d.trade_date
WHERE d.ts_code IS NULL
"""
)
)
conn.execute(
text(
f"""
DELETE c1
FROM `{COMBINED_TABLE}` c1
JOIN `{COMBINED_TABLE}` c2
ON c1.trade_date = c2.trade_date
AND c1.ts_code = c2.ts_code
WHERE
(
COALESCE(c1.source_count, 0) * 10
+ IF(c1.calc_status IS NOT NULL, 4, 0)
+ IF(c1.tushare_limit_type IS NOT NULL, 2, 0)
+ IF(c1.calc_vol IS NOT NULL, 1, 0)
) <
(
COALESCE(c2.source_count, 0) * 10
+ IF(c2.calc_status IS NOT NULL, 4, 0)
+ IF(c2.tushare_limit_type IS NOT NULL, 2, 0)
+ IF(c2.calc_vol IS NOT NULL, 1, 0)
)
OR (
(
COALESCE(c1.source_count, 0) * 10
+ IF(c1.calc_status IS NOT NULL, 4, 0)
+ IF(c1.tushare_limit_type IS NOT NULL, 2, 0)
+ IF(c1.calc_vol IS NOT NULL, 1, 0)
) =
(
COALESCE(c2.source_count, 0) * 10
+ IF(c2.calc_status IS NOT NULL, 4, 0)
+ IF(c2.tushare_limit_type IS NOT NULL, 2, 0)
+ IF(c2.calc_vol IS NOT NULL, 1, 0)
)
AND c1.id < c2.id
)
"""
)
)
def ensure_combined_indexes(conn, full_maintenance=False):
needs_unique_rebuild = index_exists(
conn, COMBINED_TABLE, "uniq_date_code"
) and not index_matches(
conn, COMBINED_TABLE, "uniq_date_code", ["trade_date", "ts_code"], unique=True
)
needs_unique_create = not index_matches(
conn, COMBINED_TABLE, "uniq_date_code", ["trade_date", "ts_code"], unique=True
)
if full_maintenance or needs_unique_rebuild or needs_unique_create:
print(" - 清理历史重复/无效融合数据 ...")
dedupe_combined_table(conn)
if index_exists(conn, COMBINED_TABLE, "uniq_date_code") and not index_matches(
conn, COMBINED_TABLE, "uniq_date_code", ["trade_date", "ts_code"], unique=True
):
print(" - 重建 uniq_date_code 唯一索引 ...")
conn.execute(text(f"ALTER TABLE `{COMBINED_TABLE}` DROP INDEX `uniq_date_code`"))
if not index_matches(
conn, COMBINED_TABLE, "uniq_date_code", ["trade_date", "ts_code"], unique=True
):
print(" - 创建 uniq_date_code 唯一索引 ...")
conn.execute(
text(
f"ALTER TABLE `{COMBINED_TABLE}` "
"ADD UNIQUE KEY `uniq_date_code` (`trade_date`, `ts_code`)"
)
)
if not index_exists(conn, COMBINED_TABLE, "idx_trade_date"):
print(" - 创建 idx_trade_date 索引 ...")
conn.execute(
text(f"ALTER TABLE `{COMBINED_TABLE}` ADD KEY `idx_trade_date` (`trade_date`)")
)
if not index_exists(conn, COMBINED_TABLE, "idx_symbol"):
print(" - 创建 idx_symbol 索引 ...")
conn.execute(
text(f"ALTER TABLE `{COMBINED_TABLE}` ADD KEY `idx_symbol` (`symbol`)")
)
if not index_exists(conn, COMBINED_TABLE, "idx_trade_date_status"):
print(" - 创建 idx_trade_date_status 索引 ...")
conn.execute(
text(
f"ALTER TABLE `{COMBINED_TABLE}` "
"ADD KEY `idx_trade_date_status` (`trade_date`, `final_status`)"
)
)
def optimize_combined_table(conn, full_maintenance=False):
print(" - 检查字段 ...")
ensure_combined_columns(conn)
if full_maintenance:
print(" - 从 stock_daily 全量回填 calc_vol ...")
sync_combined_vol_from_daily(conn)
else:
print(" - 跳过全量 calc_vol 回填,当前批次写入时会直接带入 vol。")
print(" - 检查索引 ...")
ensure_combined_indexes(conn, full_maintenance=full_maintenance)
print(" - 校验重复键 ...")
validate_no_duplicate_combined_rows(conn)
def validate_no_duplicate_combined_rows(conn):
duplicate_count = conn.execute(
text(
f"""
SELECT COUNT(*)
FROM (
SELECT trade_date, ts_code
FROM `{COMBINED_TABLE}`
GROUP BY trade_date, ts_code
HAVING COUNT(*) > 1
) duplicated
"""
)
).scalar()
if duplicate_count:
raise RuntimeError(f"{COMBINED_TABLE} 存在 {duplicate_count} 组重复 trade_date+ts_code 数据")
def validate_no_duplicate_frame_rows(df, frame_name):
if df.empty:
return
missing_keys = df["trade_date"].isna() | df["ts_code"].isna()
if missing_keys.any():
examples = df.loc[missing_keys, ["trade_date", "ts_code"]].head(5).to_dict("records")
raise ValueError(f"{frame_name} 存在空 trade_date 或 ts_code,示例: {examples}")
duplicated = df.duplicated(subset=["trade_date", "ts_code"], keep=False)
if duplicated.any():
examples = df.loc[duplicated, ["trade_date", "ts_code"]].head(5).to_dict("records")
raise ValueError(f"{frame_name} 存在重复 trade_date+ts_code,示例: {examples}")
def fetch_tushare_limit_list(trade_dates):
tushare_trade_dates = filter_dates_by_start(
trade_dates,
TUSHARE_LIMIT_LIST_START_DATE,
"Tushare limit_list_d",
)
if not tushare_trade_dates:
print("ℹ️ 无需调用 Tushare limit_list_d。")
return pd.DataFrame(columns=["trade_date", "symbol", "ts_code"])
ts.set_token(TUSHARE_TOKEN)
pro = ts.pro_api()
frames = []
rate_limiter = MinuteRateLimiter(TUSHARE_LIMIT_LIST_MAX_CALLS_PER_MINUTE)
total_calls = len(tushare_trade_dates) * 2
current_call = 0
fields = (
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
"open_times,up_stat,limit_times,limit"
)
print(
f"🔎 开始获取 Tushare limit_list_d:{len(tushare_trade_dates)} 个交易日,"
f"预计 {total_calls} 次调用,限速 {TUSHARE_LIMIT_LIST_MAX_CALLS_PER_MINUTE} 次/分钟。"
)
for trade_date in tushare_trade_dates:
tushare_date = pd.to_datetime(trade_date).strftime("%Y%m%d")
for limit_type in ("U", "Z"):
current_call += 1
print(f" [{current_call}/{total_calls}] 获取 {tushare_date} limit_type={limit_type} ...")
rate_limiter.wait()
try:
ts_df = pro.limit_list_d(
trade_date=tushare_date,
limit_type=limit_type,
fields=fields,
)
except Exception as exc:
print(f"⚠️ Tushare limit_list_d 获取失败: {tushare_date} {limit_type}, 错误: {exc}")
continue
if ts_df is None or ts_df.empty:
print(f" [{current_call}/{total_calls}] {tushare_date} {limit_type} 无数据。")
continue
normalized_df = normalize_tushare_limit_list(ts_df)
if not normalized_df.empty:
print(f" [{current_call}/{total_calls}] {tushare_date} {limit_type} 获取 {len(normalized_df)} 条。")
frames.append(normalized_df)
if not frames:
print("ℹ️ Tushare limit_list_d 未返回有效数据。")
return pd.DataFrame(columns=["trade_date", "symbol", "ts_code"])
result_df = pd.concat(frames, ignore_index=True).drop_duplicates(
subset=["trade_date", "ts_code", "tushare_limit_type"],
keep="first",
)
print(f"✅ Tushare limit_list_d 获取完成:{len(result_df)} 条有效记录。")
return result_df
def normalize_tushare_limit_list(ts_df):
column_map = {
"limit": "tushare_limit_type",
"industry": "tushare_industry",
"name": "tushare_name",
"close": "tushare_close",
"pct_chg": "tushare_pct_chg",
"amount": "tushare_amount",
"limit_amount": "tushare_limit_amount",
"float_mv": "tushare_float_mv",
"total_mv": "tushare_total_mv",
"turnover_ratio": "tushare_turnover_ratio",
"fd_amount": "tushare_fd_amount",
"first_time": "tushare_first_time",
"last_time": "tushare_last_time",
"open_times": "tushare_open_times",
"up_stat": "tushare_up_stat",
"limit_times": "tushare_limit_times",
}
required_columns = {"trade_date", "ts_code", "limit"}
if not required_columns.issubset(ts_df.columns):
missing_columns = sorted(required_columns - set(ts_df.columns))
print(f"⚠️ Tushare 返回字段缺失,已跳过: {', '.join(missing_columns)}")
return pd.DataFrame(columns=["trade_date", "symbol", "ts_code"])
available_map = {
source: target for source, target in column_map.items() if source in ts_df.columns
}
normalized_df = ts_df[["trade_date", "ts_code", *available_map]].rename(
columns=available_map
).copy()
normalized_df["trade_date"] = pd.to_datetime(
normalized_df["trade_date"], format="%Y%m%d"
).dt.date
normalized_df["ts_code"] = normalized_df["ts_code"].astype(str).str.strip()
normalized_df["symbol"] = normalized_df["ts_code"].str.split(".").str[0].str.zfill(6)
for column in [
"tushare_close",
"tushare_pct_chg",
"tushare_amount",
"tushare_limit_amount",
"tushare_float_mv",
"tushare_total_mv",
"tushare_turnover_ratio",
"tushare_fd_amount",
"tushare_open_times",
"tushare_limit_times",
]:
if column in normalized_df.columns:
normalized_df[column] = pd.to_numeric(normalized_df[column], errors="coerce")
for column in [
"tushare_limit_type",
"tushare_industry",
"tushare_name",
"tushare_first_time",
"tushare_last_time",
"tushare_up_stat",
]:
if column in normalized_df.columns:
normalized_df[column] = normalized_df[column].astype(str)
return normalized_df
def build_combined_limit_data(daily_df, calc_df, tushare_df):
tushare_columns = [
"tushare_limit_type",
"tushare_industry",
"tushare_name",
"tushare_close",
"tushare_pct_chg",
"tushare_amount",
"tushare_limit_amount",
"tushare_float_mv",
"tushare_total_mv",
"tushare_turnover_ratio",
"tushare_fd_amount",
"tushare_first_time",
"tushare_last_time",
"tushare_open_times",
"tushare_up_stat",
"tushare_limit_times",
]
for column in tushare_columns:
if column not in tushare_df.columns:
tushare_df[column] = None
calc_key_df = pd.DataFrame(columns=["trade_date", "symbol"])
if not calc_df.empty:
calc_key_df = calc_df[["trade_date", "symbol"]].copy()
tushare_key_df = pd.DataFrame(columns=["trade_date", "symbol"])
if not tushare_df.empty:
tushare_key_df = tushare_df[["trade_date", "symbol"]].copy()
keys_df = pd.concat(
[calc_key_df, tushare_key_df],
ignore_index=True,
).drop_duplicates()
if keys_df.empty:
return pd.DataFrame()
for frame in [daily_df, calc_df, tushare_df, keys_df]:
if "trade_date" in frame.columns:
frame["trade_date"] = pd.to_datetime(frame["trade_date"]).dt.date
if "symbol" in frame.columns:
frame["symbol"] = frame["symbol"].astype(str).str.zfill(6)
keys_df = keys_df.drop_duplicates(subset=["trade_date", "symbol"])
canonical_df = daily_df[[
"trade_date",
"symbol",
"ts_code",
"name",
"high",
"close",
"pre_close",
"vol",
"market",
]].drop_duplicates(subset=["trade_date", "symbol"])
combined_df = keys_df.merge(
canonical_df,
how="inner",
on=["trade_date", "symbol"],
)
if combined_df.empty:
return pd.DataFrame()
if not calc_df.empty:
calc_merge_df = calc_df[[
"trade_date",
"symbol",
"status",
"limit_price",
]].rename(columns={
"status": "calc_status",
"limit_price": "calc_limit_price",
})
combined_df = combined_df.merge(
calc_merge_df,
how="left",
on=["trade_date", "symbol"],
)
else:
combined_df["calc_status"] = None
combined_df["calc_limit_price"] = None
if not tushare_df.empty:
tushare_merge_df = tushare_df[
["trade_date", "symbol", *tushare_columns]
].sort_values("tushare_limit_type").drop_duplicates(
subset=["trade_date", "symbol"],
keep="last",
)
combined_df = combined_df.merge(
tushare_merge_df,
how="left",
on=["trade_date", "symbol"],
)
else:
for column in tushare_columns:
combined_df[column] = None
combined_df["calc_high"] = combined_df["high"]
combined_df["calc_close"] = combined_df["close"]
combined_df["calc_pre_close"] = combined_df["pre_close"]
combined_df["calc_vol"] = combined_df["vol"]
combined_df["final_status"] = combined_df.apply(resolve_final_status, axis=1)
combined_df["source_flags"] = combined_df.apply(resolve_source_flags, axis=1)
combined_df["source_count"] = combined_df["source_flags"].apply(
lambda value: 0 if not value else len(value.split(","))
)
combined_df["name"] = combined_df["name"].astype(str).str.strip()
result_df = combined_df[[
"trade_date",
"symbol",
"ts_code",
"name",
"final_status",
"source_count",
"source_flags",
"calc_status",
"calc_limit_price",
"calc_high",
"calc_close",
"calc_pre_close",
"calc_vol",
"market",
*tushare_columns,
]].copy()
result_df = result_df.sort_values(
["trade_date", "ts_code", "source_count"],
ascending=[True, True, False],
)
return result_df.drop_duplicates(subset=["trade_date", "ts_code"], keep="first")
def resolve_final_status(row):
if pd.notna(row.get("calc_status")):
return int(row["calc_status"])
if row.get("tushare_limit_type") == "Z":
return 2
return 1
def resolve_source_flags(row):
flags = []
if pd.notna(row.get("calc_status")):
flags.append("calc")
if pd.notna(row.get("tushare_limit_type")):
flags.append("tushare")
return ",".join(flags)
def analyze_and_save_limits(start_date, end_date): def analyze_and_save_limits(start_date, end_date):
@@ -40,25 +658,46 @@ def analyze_and_save_limits(start_date, end_date):
engine = get_engine() engine = get_engine()
# 0. 初始化检查表结构 # 0. 初始化检查表结构
print(f"🚀 开始处理涨停/炸板数据:({start_date} -> {end_date}]")
print(f"🧱 初始化并检查融合表 {COMBINED_TABLE} ...")
init_db(engine) init_db(engine)
print("✅ 表结构、历史重复数据和唯一索引检查完成。")
requested_start_date = pd.to_datetime(start_date).date()
if requested_start_date < LOCAL_START_DATE:
print(f"ℹ️ 本地 stock_daily 暂按 {LOCAL_START_DATE} 起处理,已忽略更早日期。")
# 1. 获取基础数据和行情数据 # 1. 获取基础数据和行情数据
query = f""" query = text("""
SELECT SELECT
d.trade_date, d.symbol, d.ts_code, b.name, d.trade_date, d.symbol, d.ts_code, b.name,
d.high, d.close, d.pre_close, b.market d.high, d.close, d.pre_close, d.vol, b.market
FROM stock_daily d FROM stock_daily d
JOIN stock_basic b ON d.ts_code = b.ts_code JOIN stock_basic b
WHERE d.trade_date > '{start_date}' ON d.ts_code COLLATE utf8mb4_unicode_ci = b.ts_code COLLATE utf8mb4_unicode_ci
AND d.trade_date <= '{end_date}' WHERE d.trade_date > :start_date
AND d.trade_date <= :end_date
AND d.trade_date >= :local_start_date
AND d.pre_close IS NOT NULL
ORDER BY d.trade_date ASC ORDER BY d.trade_date ASC
""" """)
try: try:
df = pd.read_sql(query, engine) print("📥 读取 stock_daily 与 stock_basic 数据 ...")
df = pd.read_sql(
query,
engine,
params={
"start_date": start_date,
"end_date": end_date,
"local_start_date": LOCAL_START_DATE,
},
)
if df.empty: if df.empty:
print(f"⚠️ 在指定区间 ({start_date} -> {end_date}] 内未获取到数据。") print(f"⚠️ 在指定区间 ({start_date} -> {end_date}] 内未获取到数据。")
return True return True
target_dates = df['trade_date'].astype(str).unique()
print(f"✅ 读取完成:{len(df)} 行行情,覆盖 {len(target_dates)} 个交易日。")
# 2. 涨停价计算规则 # 2. 涨停价计算规则
def calc_limit(row): def calc_limit(row):
@@ -80,31 +719,50 @@ def analyze_and_save_limits(start_date, end_date):
broken_df['status'] = 2 broken_df['status'] = 2
save_df = pd.concat([limit_df, broken_df]) save_df = pd.concat([limit_df, broken_df])
print(
f"🧮 本地计算完成:涨停 {len(limit_df)} 条,炸板 {len(broken_df)} 条,"
f"合计 {len(save_df)} 条。"
)
tushare_df = fetch_tushare_limit_list(target_dates)
print("🔗 融合本地计算结果与 Tushare 数据 ...")
combined_df = build_combined_limit_data(df.copy(), save_df.copy(), tushare_df.copy())
validate_no_duplicate_frame_rows(combined_df, "融合待入库数据")
print(f"✅ 融合完成:待入库 {len(combined_df)} 条,重复键检查通过。")
with engine.begin() as conn:
delete_params = {
f"date_{idx}": value for idx, value in enumerate(target_dates)
}
placeholders = ", ".join(f":date_{idx}" for idx in range(len(target_dates)))
combined_delete_query = text(
f"DELETE FROM {COMBINED_TABLE} WHERE trade_date IN ({placeholders})"
)
print(f"🧹 清理 {len(target_dates)} 个交易日的旧融合数据 ...")
delete_result = conn.execute(combined_delete_query, delete_params)
print(f"✅ 旧数据清理完成:删除 {delete_result.rowcount} 条。")
if not combined_df.empty:
print(f"💾 写入 {COMBINED_TABLE}:{len(combined_df)} 条 ...")
combined_df.to_sql(
COMBINED_TABLE,
conn,
if_exists='append',
index=False,
chunksize=1000,
method="multi",
)
else:
print("ℹ️ 融合结果为空,本次不写入新数据。")
validate_no_duplicate_combined_rows(conn)
print("✅ 写入后数据库重复键校验通过。")
if not save_df.empty: if not save_df.empty:
# 4. 字段清洗与对齐
final_df = save_df[[
'trade_date', 'symbol', 'ts_code', 'name',
'status', 'limit_price', 'close', 'market'
]]
# 5. 【核心防重逻辑】:开启事务,先删后插
with engine.begin() as conn:
# 获取待写入的所有交易日期
target_dates = final_df['trade_date'].astype(str).unique()
date_list = ", ".join([f"'{d}'" for d in target_dates])
# 第一层逻辑防重:删除数据库中已存在的相同日期数据
delete_query = f"DELETE FROM stock_limit_data WHERE trade_date IN ({date_list})"
conn.execute(text(delete_query))
# 第二层物理防重:通过 to_sql 写入,若触发 Unique Key 冲突会抛出异常回滚
final_df.to_sql('stock_limit_data', conn, if_exists='append', index=False)
print(f"✅ 处理成功!涉及交易日: {len(target_dates)} 天") print(f"✅ 处理成功!涉及交易日: {len(target_dates)} 天")
print(f"📊 总计入库: {len(final_df)} 条记录(已自动去重并覆盖历史数据)。") print(f"📊 融合表 {COMBINED_TABLE} 入库: {len(combined_df)} 条记录。")
else: else:
print("ℹ️ 该时段内全市场无涨停或炸板记录。") print(f"ℹ️ 该时段内本地行情无涨停或炸板记录,已清理 {len(target_dates)} 个交易日的融合表旧结果。")
print(f"📊 融合表 {COMBINED_TABLE} 入库: {len(combined_df)} 条记录。")
return True return True
except Exception as e: except Exception as e:
@@ -112,15 +770,35 @@ def analyze_and_save_limits(start_date, end_date):
return False return False
def cleanup_combined_data():
engine = get_engine()
try:
init_db(engine, full_maintenance=True)
print(f"✅ {COMBINED_TABLE} 已完成历史重复数据清理、唯一索引检查和 calc_vol 回填。")
return True
except Exception as e:
print(f"❌ 清理失败: {e}")
return False
# ============================================================ # ============================================================
# 用户配置区 # 用户配置区
# ============================================================ # ============================================================
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="计算并保存涨停/炸板数据") parser = argparse.ArgumentParser(description="计算并保存涨停/炸板数据")
parser.add_argument("--start-date", default="2026-04-29", help="开始日期,格式 YYYY-MM-DD,实际查询为大于该日期") parser.add_argument("--start-date", default="2026-05-13", help="开始日期,格式 YYYY-MM-DD,实际查询为大于该日期")
parser.add_argument("--end-date", default="2026-04-29", help="结束日期,格式 YYYY-MM-DD") parser.add_argument("--end-date", default="2026-05-15", help="结束日期,格式 YYYY-MM-DD")
parser.add_argument(
"--cleanup-only",
action="store_true",
help="仅清理 stock_limit_combined_data 历史重复数据并从 stock_daily 回填 calc_vol",
)
args = parser.parse_args() args = parser.parse_args()
if args.cleanup_only:
cleanup_combined_data()
raise SystemExit(0)
# 开始日期:计算从该日期的下一个交易日开始 # 开始日期:计算从该日期的下一个交易日开始
START_DATE = args.start_date START_DATE = args.start_date
+13 -1
View File
@@ -31,7 +31,10 @@ def main():
today = datetime.now().date() today = datetime.now().date()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="按 basic -> cal_date -> daily -> limit_up 顺序同步股票数据" description=(
"按 basic -> cal_date -> daily -> limit_up -> limit_list_d "
"-> second_board_profit_cache 顺序同步股票数据"
)
) )
parser.add_argument( parser.add_argument(
"--date", "--date",
@@ -62,11 +65,20 @@ def main():
from cal_date import run as run_cal_date from cal_date import run as run_cal_date
from daily import run as run_daily from daily import run as run_daily
from limit_list_d import run as run_limit_list_d
from limit_up import analyze_and_save_limits from limit_up import analyze_and_save_limits
from second_board_profit_cache import run as run_second_board_profit_cache
run_stage("cal_date 交易日历", run_cal_date, start_yyyymmdd, end_yyyymmdd, TUSHARE_TOKEN) run_stage("cal_date 交易日历", run_cal_date, start_yyyymmdd, end_yyyymmdd, TUSHARE_TOKEN)
run_stage("daily 日行情", run_daily, start_yyyymmdd, end_yyyymmdd) run_stage("daily 日行情", run_daily, start_yyyymmdd, end_yyyymmdd)
run_stage("limit_up 涨停/炸板", analyze_and_save_limits, limit_start, limit_end) run_stage("limit_up 涨停/炸板", analyze_and_save_limits, limit_start, limit_end)
run_stage("limit_list_d 原始涨跌停数据", run_limit_list_d, start_yyyymmdd, end_yyyymmdd)
run_stage(
"second_board_profit_cache 二板获利缓存",
run_second_board_profit_cache,
start_yyyymmdd,
end_yyyymmdd,
)
print("\n全部流程执行完成") print("\n全部流程执行完成")
+468
View File
@@ -0,0 +1,468 @@
import argparse
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from sqlalchemy import text
from stock_config import get_engine
DETAIL_TABLE = "second_board_profit_detail_cache"
SUMMARY_TABLE = "second_board_profit_summary_cache"
BATCH_SIZE = 1000
def parse_date(value):
value = str(value).strip()
for fmt in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.strptime(value, fmt).date()
except ValueError:
pass
raise ValueError(f"日期格式错误: {value},应为 YYYYMMDD 或 YYYY-MM-DD")
def to_decimal(value):
if value is None:
return None
return Decimal(str(value))
def round2(value):
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def pct_change(value, base):
value = to_decimal(value)
base = to_decimal(base)
if value is None or base is None or base <= 0:
return None
return round2((value - base) / base * Decimal("100"))
def format_limit_time(value):
text_value = "" if value is None else str(value).strip()
if not text_value or text_value.lower() in ("none", "null", "nan"):
return ""
if ":" in text_value:
return text_value
if len(text_value) == 4:
text_value = f"{text_value}00"
elif len(text_value) == 5:
text_value = f"0{text_value}"
elif len(text_value) != 6:
return ""
return f"{text_value[0:2]}:{text_value[2:4]}:{text_value[4:6]}"
def time_seconds(value):
text_value = format_limit_time(value)
if not text_value:
return None
parts = text_value.split(":")
if len(parts) < 2 or len(parts) > 3:
return None
try:
hour = int(parts[0])
minute = int(parts[1])
second = int(parts[2]) if len(parts) == 3 else 0
except ValueError:
return None
if hour > 23 or minute > 59 or second > 59 or min(hour, minute, second) < 0:
return None
return hour * 3600 + minute * 60 + second
def is_target_limit_time(value):
seconds = time_seconds(value)
if seconds is None:
return False
return 9 * 3600 + 31 * 60 <= seconds <= 9 * 3600 + 59 * 60 + 59
def init_db(engine):
detail_sql = f"""
CREATE TABLE IF NOT EXISTS `{DETAIL_TABLE}` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`trade_date` DATE NOT NULL COMMENT 'T日交易日',
`previous_trade_date` DATE NOT NULL COMMENT 'T-1交易日',
`previous_two_trade_date` DATE NOT NULL COMMENT 'T-2交易日',
`next_trade_date` DATE NULL COMMENT 'T+1交易日',
`symbol` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '股票代码',
`ts_code` VARCHAR(20) NOT NULL COMMENT 'TuShare股票代码',
`name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '股票简称',
`market` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '所属板块',
`industry` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所属行业',
`previous_close` DECIMAL(16,4) NULL COMMENT 'T-1收盘价',
`limit_price` DECIMAL(16,4) NULL COMMENT 'T日涨停价',
`t_high` DECIMAL(16,4) NULL COMMENT 'T日最高价',
`t_close` DECIMAL(16,4) NULL COMMENT 'T日收盘价',
`t_pct_chg` DECIMAL(10,4) NULL COMMENT 'T日涨跌幅',
`status` VARCHAR(10) NOT NULL DEFAULT '' COMMENT 'limit封板 break炸板',
`first_limit_time` VARCHAR(8) NOT NULL DEFAULT '' COMMENT 'T日首次涨停时间',
`open_times` INT NULL COMMENT '开板次数',
`next_high` DECIMAL(16,4) NULL COMMENT 'T+1最高价',
`next_close` DECIMAL(16,4) NULL COMMENT 'T+1收盘价',
`next_intraday_profitable` TINYINT(1) NULL COMMENT 'T+1盘中是否盈利',
`next_high_profit_pct` DECIMAL(10,2) NULL COMMENT 'T+1最大获利百分比',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_trade_code` (`trade_date`, `ts_code`),
KEY `idx_ts_code_date` (`ts_code`, `trade_date`),
KEY `idx_trade_date` (`trade_date`),
KEY `idx_total_profit` (`next_high_profit_pct`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
summary_sql = f"""
CREATE TABLE IF NOT EXISTS `{SUMMARY_TABLE}` (
`ts_code` VARCHAR(20) NOT NULL COMMENT 'TuShare股票代码',
`symbol` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '股票代码',
`name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '股票简称',
`market` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '所属板块',
`industry` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所属行业',
`second_board_count` INT NOT NULL DEFAULT 0 COMMENT '二板次数',
`limit_count` INT NOT NULL DEFAULT 0 COMMENT '封板次数',
`break_count` INT NOT NULL DEFAULT 0 COMMENT '炸板次数',
`profit_data_count` INT NOT NULL DEFAULT 0 COMMENT '具备收益数据次数',
`total_next_high_profit_pct` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '最大获利累计百分比',
`average_next_high_profit_pct` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '最大获利平均百分比',
`first_trade_date` DATE NULL COMMENT '首次命中日期',
`last_trade_date` DATE NULL COMMENT '最近命中日期',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`ts_code`),
KEY `idx_total_profit` (`total_next_high_profit_pct`),
KEY `idx_last_trade_date` (`last_trade_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
with engine.begin() as conn:
conn.execute(text(detail_sql))
conn.execute(text(summary_sql))
print(f"✅ 表 {DETAIL_TABLE} / {SUMMARY_TABLE} 检查完成。")
def load_candidate_rows(conn, start_date, end_date):
query = text(
"""
WITH calendar AS (
SELECT
cal_date AS trade_date,
LAG(cal_date) 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) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1
),
calendar_window AS (
SELECT *
FROM calendar
WHERE trade_date BETWEEN :start_date AND :end_date
)
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,
c.previous_two_trade_date,
c.next_trade_date,
d.symbol,
pl.ts_code,
COALESCE(NULLIF(sb.name, ''), NULLIF(pl.name, ''), '') AS name,
COALESCE(NULLIF(sb.market, ''), '') AS market,
COALESCE(NULLIF(sb.industry, ''), NULLIF(pl.industry, ''), '') AS industry,
COALESCE(d.pre_close, pl.close) AS previous_close,
ROUND(
COALESCE(d.pre_close, pl.close) *
CASE
WHEN COALESCE(sb.market, '') = '北交所' THEN 1.30
WHEN COALESCE(sb.market, '') IN ('创业板', '科创板') THEN 1.20
WHEN COALESCE(sb.name, pl.name, '') LIKE '%ST%' THEN 1.05
ELSE 1.10
END,
2
) AS limit_price,
d.high AS t_high,
d.close AS t_close,
d.pct_chg AS t_pct_chg,
CASE
WHEN ABS(d.close - d.high) < 0.0001 THEN 'limit'
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 calendar_window 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 COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
AND ppl.trade_date = c.previous_two_trade_date
AND ppl.`limit` = 'U'
INNER JOIN stock_daily d
ON d.ts_code COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
AND d.trade_date = c.trade_date
LEFT JOIN stock_daily nd
ON nd.ts_code COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
AND nd.trade_date = c.next_trade_date
LEFT JOIN limit_list_d tlu
ON tlu.ts_code COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
AND tlu.trade_date = c.trade_date
AND tlu.`limit` = 'U'
LEFT JOIN limit_list_d tlz
ON tlz.ts_code COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
AND tlz.trade_date = c.trade_date
AND tlz.`limit` = 'Z'
LEFT JOIN stock_basic sb
ON sb.ts_code COLLATE utf8mb4_unicode_ci = pl.ts_code COLLATE utf8mb4_unicode_ci
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
) candidate
WHERE previous_close IS NOT NULL
AND limit_price IS NOT NULL
AND limit_price > 0
AND t_high >= limit_price - 0.0001
AND status IN ('limit', 'break')
ORDER BY trade_date ASC, ts_code ASC
"""
)
return [
dict(row)
for row in conn.execute(
query,
{"start_date": start_date, "end_date": end_date},
).mappings()
]
def build_detail_records(rows):
records = []
for row in rows:
first_limit_time = format_limit_time(row["first_limit_time"])
if not is_target_limit_time(first_limit_time):
continue
next_intraday_profitable = None
if row["next_high"] is not None and row["t_high"] is not None:
next_intraday_profitable = (
1 if to_decimal(row["next_high"]) > to_decimal(row["t_high"]) else 0
)
records.append(
{
"trade_date": row["trade_date"],
"previous_trade_date": row["previous_trade_date"],
"previous_two_trade_date": row["previous_two_trade_date"],
"next_trade_date": row["next_trade_date"] or None,
"symbol": row["symbol"] or "",
"ts_code": row["ts_code"],
"name": row["name"] or "",
"market": row["market"] or "",
"industry": row["industry"] or "",
"previous_close": row["previous_close"],
"limit_price": row["limit_price"],
"t_high": row["t_high"],
"t_close": row["t_close"],
"t_pct_chg": row["t_pct_chg"],
"status": row["status"],
"first_limit_time": first_limit_time,
"open_times": row["open_times"],
"next_high": row["next_high"],
"next_close": row["next_close"],
"next_intraday_profitable": next_intraday_profitable,
"next_high_profit_pct": pct_change(row["next_high"], row["t_high"]),
}
)
return records
def load_existing_keys(conn, start_date, end_date):
query = text(
f"""
SELECT DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, ts_code
FROM `{DETAIL_TABLE}`
WHERE trade_date BETWEEN :start_date AND :end_date
"""
)
rows = conn.execute(
query,
{"start_date": start_date, "end_date": end_date},
).mappings()
return {(row["trade_date"], row["ts_code"]) for row in rows}
def write_detail_records(conn, records, start_date, end_date):
existing_keys = load_existing_keys(conn, start_date, end_date)
overwritten_count = sum(
1 for record in records if (record["trade_date"], record["ts_code"]) in existing_keys
)
delete_result = conn.execute(
text(
f"""
DELETE FROM `{DETAIL_TABLE}`
WHERE trade_date BETWEEN :start_date AND :end_date
"""
),
{"start_date": start_date, "end_date": end_date},
)
if not records:
return 0, overwritten_count, delete_result.rowcount
sql = text(
f"""
INSERT INTO `{DETAIL_TABLE}` (
trade_date, previous_trade_date, previous_two_trade_date, next_trade_date,
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, next_intraday_profitable, next_high_profit_pct
) VALUES (
:trade_date, :previous_trade_date, :previous_two_trade_date, :next_trade_date,
: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, :next_intraday_profitable, :next_high_profit_pct
)
ON DUPLICATE KEY UPDATE
previous_trade_date = VALUES(previous_trade_date),
previous_two_trade_date = VALUES(previous_two_trade_date),
next_trade_date = VALUES(next_trade_date),
symbol = VALUES(symbol),
name = VALUES(name),
market = VALUES(market),
industry = VALUES(industry),
previous_close = VALUES(previous_close),
limit_price = VALUES(limit_price),
t_high = VALUES(t_high),
t_close = VALUES(t_close),
t_pct_chg = VALUES(t_pct_chg),
status = VALUES(status),
first_limit_time = VALUES(first_limit_time),
open_times = VALUES(open_times),
next_high = VALUES(next_high),
next_close = VALUES(next_close),
next_intraday_profitable = VALUES(next_intraday_profitable),
next_high_profit_pct = VALUES(next_high_profit_pct)
"""
)
for index in range(0, len(records), BATCH_SIZE):
conn.execute(sql, records[index:index + BATCH_SIZE])
return len(records), overwritten_count, delete_result.rowcount
def refresh_summary(conn):
conn.execute(text(f"TRUNCATE TABLE `{SUMMARY_TABLE}`"))
conn.execute(
text(
f"""
INSERT INTO `{SUMMARY_TABLE}` (
ts_code, symbol, name, market, industry, second_board_count,
limit_count, break_count, profit_data_count, total_next_high_profit_pct,
average_next_high_profit_pct, first_trade_date, last_trade_date
)
SELECT
ts_code,
MAX(symbol) AS symbol,
MAX(name) AS name,
MAX(market) AS market,
MAX(industry) AS industry,
COUNT(*) AS second_board_count,
SUM(CASE WHEN status = 'limit' THEN 1 ELSE 0 END) AS limit_count,
SUM(CASE WHEN status = 'break' THEN 1 ELSE 0 END) AS break_count,
COUNT(next_high_profit_pct) AS profit_data_count,
ROUND(SUM(COALESCE(next_high_profit_pct, 0)), 2) AS total_next_high_profit_pct,
COALESCE(
ROUND(
SUM(COALESCE(next_high_profit_pct, 0))
/ NULLIF(COUNT(next_high_profit_pct), 0),
2
),
0
) AS average_next_high_profit_pct,
MIN(trade_date) AS first_trade_date,
MAX(trade_date) AS last_trade_date
FROM `{DETAIL_TABLE}`
GROUP BY ts_code
"""
)
)
def run(start_date, end_date):
start = parse_date(start_date)
end = parse_date(end_date)
if start > end:
raise ValueError("开始日期不能大于结束日期")
engine = get_engine()
print(f"🚀 开始刷新二板获利缓存:{start} ~ {end}")
try:
init_db(engine)
with engine.begin() as conn:
rows = load_candidate_rows(conn, start, end)
records = build_detail_records(rows)
write_count, overwritten_count, deleted_count = write_detail_records(
conn,
records,
start,
end,
)
refresh_summary(conn)
print(f"候选记录:{len(rows)} 条")
print(f"命中记录:{len(records)} 条")
print(f"清理旧记录:{deleted_count} 条")
print(f"写入记录:{write_count} 条")
print(f"覆盖记录:{overwritten_count} 条")
print("✅ 二板获利缓存刷新完成。")
return True
except Exception as exc:
print(f"❌ 二板获利缓存刷新失败: {exc}")
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="刷新二板获利缓存")
parser.add_argument("--start-date", default="20260515", help="开始日期,格式 YYYYMMDD 或 YYYY-MM-DD")
parser.add_argument("--end-date", default="20260515", help="结束日期,格式 YYYYMMDD 或 YYYY-MM-DD")
args = parser.parse_args()
run(args.start_date, args.end_date)
+14 -3
View File
@@ -6,11 +6,22 @@ TUSHARE_TOKEN = os.getenv(
"0fad3cf498757089e2630028455d5bbe1637475788bcdaa1f2175e93", "0fad3cf498757089e2630028455d5bbe1637475788bcdaa1f2175e93",
) )
# # 阿里云远程数据库
# DB_CONFIG = {
# "host": os.getenv("MYSQL_HOST", "8.159.129.156"),
# "port": int(os.getenv("MYSQL_PORT", "10836")),
# "user": os.getenv("MYSQL_USER", "yangfan"),
# "password": os.getenv("MYSQL_PASSWORD", "aA%8023321088"),
# "database": os.getenv("MYSQL_DATABASE", "level"),
# "charset": os.getenv("MYSQL_CHARSET", "utf8mb4"),
# }
# mac本地mysql
DB_CONFIG = { DB_CONFIG = {
"host": os.getenv("MYSQL_HOST", "8.159.129.156"), "host": os.getenv("MYSQL_HOST", "localhost"),
"port": int(os.getenv("MYSQL_PORT", "10836")), "port": int(os.getenv("MYSQL_PORT", "3306")),
"user": os.getenv("MYSQL_USER", "yangfan"), "user": os.getenv("MYSQL_USER", "yangfan"),
"password": os.getenv("MYSQL_PASSWORD", "aA%8023321088"), "password": os.getenv("MYSQL_PASSWORD", "admin"),
"database": os.getenv("MYSQL_DATABASE", "level"), "database": os.getenv("MYSQL_DATABASE", "level"),
"charset": os.getenv("MYSQL_CHARSET", "utf8mb4"), "charset": os.getenv("MYSQL_CHARSET", "utf8mb4"),
} }