from __future__ import annotations import argparse import os import re import sys import time from datetime import datetime from pathlib import Path import pymysql MARKET_SUFFIX = { "SH": ".SH", "SZ": ".SZ", "BJ": ".BJ", } def connect(args: argparse.Namespace): password = args.password or os.environ.get("TIANXIA_MYSQL_PASSWORD") if not password: raise RuntimeError("missing MySQL password: pass --password or set TIANXIA_MYSQL_PASSWORD") return pymysql.connect( host=args.host, port=args.port, user=args.user, password=password, database=args.database, charset="utf8mb4", autocommit=False, local_infile=True, ) def ensure_schema(conn) -> None: ddl = [ """ CREATE TABLE IF NOT EXISTS a_share_minute_price_full_adj ( trade_date date NOT NULL, trade_time time NOT NULL, bar_time datetime NOT NULL, symbol char(9) NOT NULL, market varchar(8) NOT NULL, open_price decimal(14,4) NULL, high_price decimal(14,4) NULL, low_price decimal(14,4) NULL, close_price decimal(14,4) NULL, volume decimal(24,4) NULL, amount decimal(24,4) NULL, source_file varchar(255) NOT NULL, source_archive varchar(255) NOT NULL, import_run_id varchar(96) NOT NULL, imported_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (symbol, trade_date, trade_time), KEY idx_full_adj_minute_date_symbol_time (trade_date, symbol, trade_time), KEY idx_full_adj_minute_run (import_run_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, """ CREATE TABLE IF NOT EXISTS a_share_minute_full_adj_import_run ( run_id varchar(96) NOT NULL, started_at datetime NOT NULL, finished_at datetime NULL, status varchar(32) NOT NULL, source_archive varchar(512) NOT NULL, source_root varchar(512) NOT NULL, files_total int NOT NULL DEFAULT 0, files_loaded int NOT NULL DEFAULT 0, rows_loaded bigint NOT NULL DEFAULT 0, min_trade_date date NULL, max_trade_date date NULL, error_message text NULL, PRIMARY KEY (run_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, """ CREATE TABLE IF NOT EXISTS a_share_minute_full_adj_import_file_log ( id bigint NOT NULL AUTO_INCREMENT, run_id varchar(96) NOT NULL, symbol char(9) NOT NULL, market varchar(8) NOT NULL, source_file varchar(512) NOT NULL, file_size bigint NOT NULL, rows_loaded bigint NOT NULL, started_at datetime NOT NULL, finished_at datetime NOT NULL, duration_ms bigint NOT NULL, status varchar(32) NOT NULL, error_message text NULL, PRIMARY KEY (id), UNIQUE KEY uq_full_adj_file_run (run_id, source_file), KEY idx_full_adj_file_symbol (symbol), KEY idx_full_adj_file_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, ] with conn.cursor() as cur: for sql in ddl: cur.execute(sql) conn.commit() def discover_files(root: Path) -> list[Path]: files = sorted(root.rglob("price_*.csv")) if not files: raise RuntimeError(f"no price_*.csv files found under {root}") return files def symbol_for(path: Path) -> tuple[str, str]: market = path.parent.name.upper() suffix = MARKET_SUFFIX.get(market) if not suffix: raise RuntimeError(f"unsupported market directory {market}: {path}") m = re.match(r"price_(\d{6})\.csv$", path.name, re.IGNORECASE) if not m: raise RuntimeError(f"unsupported file name: {path}") return m.group(1) + suffix, market def init_run(conn, run_id: str, source_archive: str, source_root: str, files_total: int) -> None: with conn.cursor() as cur: cur.execute( """ INSERT INTO a_share_minute_full_adj_import_run (run_id, started_at, status, source_archive, source_root, files_total) VALUES (%s, NOW(), 'running', %s, %s, %s) ON DUPLICATE KEY UPDATE status='running', source_archive=VALUES(source_archive), source_root=VALUES(source_root), files_total=VALUES(files_total), error_message=NULL """, (run_id, source_archive, source_root, files_total), ) conn.commit() def finish_run(conn, run_id: str, status: str, error: str | None = None) -> None: with conn.cursor() as cur: cur.execute( """ UPDATE a_share_minute_full_adj_import_run r LEFT JOIN ( SELECT import_run_id, COUNT(*) AS rows_loaded, MIN(trade_date) AS min_trade_date, MAX(trade_date) AS max_trade_date FROM a_share_minute_price_full_adj WHERE import_run_id=%s GROUP BY import_run_id ) x ON x.import_run_id = r.run_id SET r.finished_at=NOW(), r.status=%s, r.rows_loaded=COALESCE(x.rows_loaded, r.rows_loaded), r.min_trade_date=COALESCE(x.min_trade_date, r.min_trade_date), r.max_trade_date=COALESCE(x.max_trade_date, r.max_trade_date), r.error_message=%s WHERE r.run_id=%s """, (run_id, status, error, run_id), ) conn.commit() def load_file(conn, path: Path, root: Path, archive: str, run_id: str) -> int: symbol, market = symbol_for(path) rel = str(path.relative_to(root)).replace("\\", "/") started = datetime.now() t0 = time.perf_counter() with conn.cursor() as cur: cur.execute("DROP TEMPORARY TABLE IF EXISTS tmp_full_adj_minute_csv") cur.execute( """ CREATE TEMPORARY TABLE tmp_full_adj_minute_csv ( timetag varchar(19) NOT NULL, open_price decimal(14,4) NULL, high_price decimal(14,4) NULL, low_price decimal(14,4) NULL, close_price decimal(14,4) NULL, volume decimal(24,4) NULL, amount decimal(24,4) NULL ) ENGINE=InnoDB """ ) cur.execute( """ LOAD DATA LOCAL INFILE %s INTO TABLE tmp_full_adj_minute_csv CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (timetag, open_price, high_price, low_price, close_price, volume, amount) """, (str(path),), ) loaded_tmp = cur.rowcount cur.execute( """ INSERT INTO a_share_minute_price_full_adj (trade_date, trade_time, bar_time, symbol, market, open_price, high_price, low_price, close_price, volume, amount, source_file, source_archive, import_run_id) SELECT DATE(STR_TO_DATE(timetag, '%%Y%%m%%d %%H:%%i:%%s')), TIME(STR_TO_DATE(timetag, '%%Y%%m%%d %%H:%%i:%%s')), STR_TO_DATE(timetag, '%%Y%%m%%d %%H:%%i:%%s'), %s, %s, open_price, high_price, low_price, close_price, volume, amount, %s, %s, %s FROM tmp_full_adj_minute_csv ON DUPLICATE KEY UPDATE open_price=VALUES(open_price), high_price=VALUES(high_price), low_price=VALUES(low_price), close_price=VALUES(close_price), volume=VALUES(volume), amount=VALUES(amount), source_file=VALUES(source_file), source_archive=VALUES(source_archive), import_run_id=VALUES(import_run_id), imported_at=CURRENT_TIMESTAMP """, (symbol, market, rel, archive, run_id), ) inserted = cur.rowcount duration_ms = int((time.perf_counter() - t0) * 1000) cur.execute( """ INSERT INTO a_share_minute_full_adj_import_file_log (run_id, symbol, market, source_file, file_size, rows_loaded, started_at, finished_at, duration_ms, status) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), %s, 'loaded') ON DUPLICATE KEY UPDATE rows_loaded=VALUES(rows_loaded), file_size=VALUES(file_size), finished_at=VALUES(finished_at), duration_ms=VALUES(duration_ms), status='loaded', error_message=NULL """, (run_id, symbol, market, rel, path.stat().st_size, loaded_tmp, started, duration_ms), ) cur.execute( """ UPDATE a_share_minute_full_adj_import_run SET files_loaded = ( SELECT COUNT(*) FROM a_share_minute_full_adj_import_file_log WHERE run_id=%s AND status='loaded' ) WHERE run_id=%s """, (run_id, run_id), ) conn.commit() return loaded_tmp if loaded_tmp >= 0 else inserted def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", required=True) parser.add_argument("--source-archive", required=True) parser.add_argument("--run-id") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=3306) parser.add_argument("--user", default="root") parser.add_argument("--password") parser.add_argument("--database", default="tianxia") parser.add_argument("--limit", type=int) parser.add_argument("--skip-loaded", action="store_true") args = parser.parse_args() root = Path(args.source_root).resolve() files = discover_files(root) if args.limit: files = files[: args.limit] run_id = args.run_id or "full_adj_minute_" + datetime.now().strftime("%Y%m%d_%H%M%S") conn = connect(args) try: ensure_schema(conn) init_run(conn, run_id, args.source_archive, str(root), len(files)) loaded_total = 0 with conn.cursor() as cur: loaded_files = set() if args.skip_loaded: cur.execute( "SELECT source_file FROM a_share_minute_full_adj_import_file_log WHERE run_id=%s AND status='loaded'", (run_id,), ) loaded_files = {row[0] for row in cur.fetchall()} for idx, path in enumerate(files, 1): rel = str(path.relative_to(root)).replace("\\", "/") if rel in loaded_files: continue rows = load_file(conn, path, root, args.source_archive, run_id) loaded_total += rows if idx % 10 == 0 or idx == 1 or idx == len(files): print(f"{datetime.now():%Y-%m-%d %H:%M:%S} loaded {idx}/{len(files)} files; last={rel}; rows={rows}; total_rows_seen={loaded_total}", flush=True) finish_run(conn, run_id, "completed") print(f"completed run_id={run_id} files={len(files)} rows_seen={loaded_total}") return 0 except Exception as exc: conn.rollback() try: finish_run(conn, run_id, "failed", str(exc)) except Exception: pass print(f"failed run_id={run_id}: {exc}", file=sys.stderr) return 1 finally: conn.close() if __name__ == "__main__": raise SystemExit(main())