Files
python/limit_up.py
T
2026-05-05 14:13:11 +08:00

628 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pandas as pd
from sqlalchemy import text
import argparse
import tushare as ts
from datetime import date
from stock_config import TUSHARE_TOKEN, get_engine
# --- 数据库配置 ---
# 请根据实际情况修改数据库连接信息
# 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)
COMBINED_TABLE = "stock_limit_combined_data"
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):
"""
初始化数据库表结构
"""
with engine.begin() as conn:
conn.execute(text(get_combined_table_sql()))
optimize_combined_table(conn)
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前收盘价',
`vol` BIGINT 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 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 ensure_combined_columns(conn):
existing_columns = get_table_columns(conn, COMBINED_TABLE)
columns = {
"vol": "BIGINT COMMENT 'stock_daily成交量'",
"calc_vol": "BIGINT COMMENT 'stock_daily成交量'",
}
for column_name, column_def in columns.items():
if column_name not in existing_columns:
after_column = "calc_pre_close" if column_name == "vol" else "vol"
conn.execute(
text(
f"ALTER TABLE `{COMBINED_TABLE}` "
f"ADD COLUMN `{column_name}` {column_def} AFTER `{after_column}`"
)
)
def sync_combined_vol_from_daily(conn):
conn.execute(
text(
f"""
UPDATE `{COMBINED_TABLE}` c
JOIN `stock_daily` d
ON c.trade_date = d.trade_date
AND c.ts_code COLLATE utf8mb4_unicode_ci = d.ts_code COLLATE utf8mb4_unicode_ci
SET c.symbol = d.symbol,
c.vol = d.vol,
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.trade_date = d.trade_date
AND c.ts_code COLLATE utf8mb4_unicode_ci = d.ts_code COLLATE utf8mb4_unicode_ci
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 COLLATE utf8mb4_unicode_ci = c2.ts_code COLLATE utf8mb4_unicode_ci
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.vol IS NOT NULL OR 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.vol IS NOT NULL OR 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.vol IS NOT NULL OR 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.vol IS NOT NULL OR c2.calc_vol IS NOT NULL, 1, 0)
)
AND c1.id < c2.id
)
"""
)
)
def ensure_combined_indexes(conn):
dedupe_combined_table(conn)
if not index_exists(conn, COMBINED_TABLE, "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"):
conn.execute(
text(f"ALTER TABLE `{COMBINED_TABLE}` ADD KEY `idx_trade_date` (`trade_date`)")
)
if not index_exists(conn, COMBINED_TABLE, "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"):
conn.execute(
text(
f"ALTER TABLE `{COMBINED_TABLE}` "
"ADD KEY `idx_trade_date_status` (`trade_date`, `final_status`)"
)
)
def optimize_combined_table(conn):
ensure_combined_columns(conn)
sync_combined_vol_from_daily(conn)
ensure_combined_indexes(conn)
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:
return pd.DataFrame(columns=["trade_date", "symbol", "ts_code"])
ts.set_token(TUSHARE_TOKEN)
pro = ts.pro_api()
frames = []
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"
)
for trade_date in tushare_trade_dates:
tushare_date = pd.to_datetime(trade_date).strftime("%Y%m%d")
for limit_type in ("U", "Z"):
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:
continue
normalized_df = normalize_tushare_limit_list(ts_df)
if not normalized_df.empty:
frames.append(normalized_df)
if not frames:
return pd.DataFrame(columns=["trade_date", "symbol", "ts_code"])
return pd.concat(frames, ignore_index=True).drop_duplicates(
subset=["trade_date", "ts_code", "tushare_limit_type"],
keep="first",
)
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["vol"] = combined_df["vol"]
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",
"vol",
"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):
"""
计算并在数据库中落地涨停、炸板详情
"""
engine = get_engine()
# 0. 初始化检查表结构
init_db(engine)
requested_start_date = pd.to_datetime(start_date).date()
if requested_start_date < LOCAL_START_DATE:
print(f"ℹ️ 本地 stock_daily 暂按 {LOCAL_START_DATE} 起处理,已忽略更早日期。")
# 1. 获取基础数据和行情数据
query = text("""
SELECT
d.trade_date, d.symbol, d.ts_code, b.name,
d.high, d.close, d.pre_close, d.vol, b.market
FROM stock_daily d
JOIN stock_basic b
ON d.ts_code COLLATE utf8mb4_unicode_ci = b.ts_code COLLATE utf8mb4_unicode_ci
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
""")
try:
df = pd.read_sql(
query,
engine,
params={
"start_date": start_date,
"end_date": end_date,
"local_start_date": LOCAL_START_DATE,
},
)
if df.empty:
print(f"⚠️ 在指定区间 ({start_date} -> {end_date}] 内未获取到数据。")
return True
# 2. 涨停价计算规则
def calc_limit(row):
m, p = str(row['market']), float(row['pre_close'])
# 主板10%,创业板/科创板20%,北交所30%
ratio = 1.2 if ('创业板' in m or '科创板' in m) else (1.3 if '北交所' in m else 1.1)
return round(p * ratio, 2)
df['limit_price'] = df.apply(calc_limit, axis=1)
# 3. 筛选涨停(1)和炸板(2)
df['is_limit'] = (df['high'] == df['limit_price']) & (df['close'] == df['limit_price'])
df['is_broken'] = (df['high'] == df['limit_price']) & (df['close'] < df['limit_price'])
limit_df = df[df['is_limit']].copy()
limit_df['status'] = 1
broken_df = df[df['is_broken']].copy()
broken_df['status'] = 2
save_df = pd.concat([limit_df, broken_df])
target_dates = df['trade_date'].astype(str).unique()
tushare_df = fetch_tushare_limit_list(target_dates)
combined_df = build_combined_limit_data(df.copy(), save_df.copy(), tushare_df.copy())
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})"
)
conn.execute(combined_delete_query, delete_params)
if not combined_df.empty:
combined_df.to_sql(
COMBINED_TABLE,
conn,
if_exists='append',
index=False,
chunksize=1000,
method="multi",
)
if not save_df.empty:
print(f"✅ 处理成功!涉及交易日: {len(target_dates)} 天")
print(f"📊 融合表 {COMBINED_TABLE} 入库: {len(combined_df)} 条记录。")
else:
print(f"ℹ️ 该时段内本地行情无涨停或炸板记录,已清理 {len(target_dates)} 个交易日的融合表旧结果。")
print(f"📊 融合表 {COMBINED_TABLE} 入库: {len(combined_df)} 条记录。")
return True
except Exception as e:
print(f"❌ 运行失败: {e}")
return False
def cleanup_combined_data():
engine = get_engine()
try:
init_db(engine)
print(f"✅ {COMBINED_TABLE} 已完成历史重复数据清理、唯一索引检查和 vol 回填。")
return True
except Exception as e:
print(f"❌ 清理失败: {e}")
return False
# ============================================================
# 用户配置区
# ============================================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="计算并保存涨停/炸板数据")
parser.add_argument("--start-date", default="2026-03-02", help="开始日期,格式 YYYY-MM-DD,实际查询为大于该日期")
parser.add_argument("--end-date", default="2026-04-30", help="结束日期,格式 YYYY-MM-DD")
parser.add_argument(
"--cleanup-only",
action="store_true",
help="仅清理 stock_limit_combined_data 历史重复数据并从 stock_daily 回填 vol",
)
args = parser.parse_args()
if args.cleanup_only:
cleanup_combined_data()
raise SystemExit(0)
# 开始日期:计算从该日期的下一个交易日开始
START_DATE = args.start_date
# 结束日期
END_DATE = args.end_date
analyze_and_save_limits(START_DATE, END_DATE)