722 lines
25 KiB
Python
Executable File
722 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
5分钟K线数据每日采集脚本
|
||
|
||
功能:每日收盘后自动采集全市场A股的5分钟K线数据,存入 stock_kline_5min 表。
|
||
数据源:akshare stock_zh_a_minute(新浪财经,免费,腾讯云可用)
|
||
|
||
特点:
|
||
- 增量采集:只采集当日新增数据
|
||
- 断点续传:记录已采集的股票,中断后可继续
|
||
- 频率控制:自动限速避免触发API封禁
|
||
- 回填模式:可手动回填最近N天的历史分钟数据
|
||
|
||
用法:
|
||
# 每日采集(推荐在 17:00 后运行,收盘后数据完整)
|
||
./venv/bin/python sync_kline_5min.py
|
||
|
||
# 回填最近5天
|
||
./venv/bin/python sync_kline_5min.py --backfill 5
|
||
|
||
# 只采集指定股票
|
||
./venv/bin/python sync_kline_5min.py --codes 300720,000001
|
||
|
||
# 快速测试(只采集前10只)
|
||
./venv/bin/python sync_kline_5min.py --limit 10
|
||
|
||
建议定时任务:
|
||
30 17 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_kline_5min.py >> /opt/stock-app/sync_kline_5min.log 2>&1
|
||
"""
|
||
import sys
|
||
import os
|
||
import time
|
||
import argparse
|
||
import signal as sig_module
|
||
import fcntl
|
||
import atexit
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import datetime, date, timedelta
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
import psycopg2
|
||
from psycopg2.extras import execute_values
|
||
from config import Config
|
||
|
||
# ============ 配置 ============
|
||
API_DELAY = 0.8 # 每次API调用间隔(秒),东财限流严格需更保守
|
||
BATCH_SAVE_SIZE = 500 # 每批保存行数
|
||
PERIOD = '5' # K线级别:'1','5','15','30','60'
|
||
MAX_RETRIES = 2 # 单只股票API重试次数
|
||
RETRY_DELAY = 3 # 重试等待时间(秒)
|
||
EM_CIRCUIT_BREAKER = 3 # 东财API连续失败N次后暂停使用(快速熔断)
|
||
LOCK_FILE = '/tmp/sync_kline_5min.lock'
|
||
DEFAULT_WORKERS = 3 # 默认并发数(东财API限流严格,不宜过高)
|
||
|
||
_shutdown = False
|
||
_em_consecutive_errors = 0 # 东财API连续错误计数
|
||
_em_disabled = False # 东财API是否被暂停
|
||
_em_lock = threading.Lock() # 东财API状态锁
|
||
_lock_fd = None # 进程锁文件描述符
|
||
|
||
|
||
def _clean_stale_lock():
|
||
"""清理残留的锁文件(进程已不存在或权限不对时)"""
|
||
if not os.path.exists(LOCK_FILE):
|
||
return
|
||
try:
|
||
with open(LOCK_FILE, 'r') as f:
|
||
old_pid = f.read().strip()
|
||
if old_pid and old_pid.isdigit():
|
||
try:
|
||
os.kill(int(old_pid), 0) # 检查进程是否存在
|
||
return # 进程仍在运行,不清理
|
||
except ProcessLookupError:
|
||
pass # 进程已不存在,清理
|
||
except PermissionError:
|
||
return # 进程存在但无权限检查,不清理
|
||
except (IOError, PermissionError):
|
||
pass # 无法读取锁文件,尝试删除
|
||
try:
|
||
os.remove(LOCK_FILE)
|
||
print(f"🧹 已清理残留锁文件 (旧PID: {old_pid if 'old_pid' in dir() else '未知'})", flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def acquire_lock():
|
||
"""获取进程锁,防止多实例同时运行"""
|
||
global _lock_fd
|
||
# 先尝试清理残留的锁文件
|
||
_clean_stale_lock()
|
||
try:
|
||
_lock_fd = open(LOCK_FILE, 'w')
|
||
except PermissionError:
|
||
# 锁文件权限不对,尝试删除后重建
|
||
try:
|
||
os.remove(LOCK_FILE)
|
||
_lock_fd = open(LOCK_FILE, 'w')
|
||
except Exception as e:
|
||
print(f"⚠️ 无法创建锁文件 {LOCK_FILE}: {e}", flush=True)
|
||
return False
|
||
try:
|
||
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
_lock_fd.write(str(os.getpid()))
|
||
_lock_fd.flush()
|
||
atexit.register(release_lock)
|
||
return True
|
||
except IOError:
|
||
# 另一个实例正在运行,读取其PID
|
||
try:
|
||
with open(LOCK_FILE, 'r') as f:
|
||
old_pid = f.read().strip()
|
||
print(f"⚠️ 另一个实例正在运行 (PID: {old_pid}),退出", flush=True)
|
||
except Exception:
|
||
print(f"⚠️ 另一个实例正在运行,退出", flush=True)
|
||
_lock_fd.close()
|
||
_lock_fd = None
|
||
return False
|
||
|
||
|
||
def release_lock():
|
||
"""释放进程锁"""
|
||
global _lock_fd
|
||
if _lock_fd:
|
||
try:
|
||
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||
_lock_fd.close()
|
||
except Exception:
|
||
pass
|
||
_lock_fd = None
|
||
try:
|
||
os.remove(LOCK_FILE)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def signal_handler(signum, frame):
|
||
global _shutdown
|
||
print("\n⚠️ 收到中断信号,正在优雅退出...", flush=True)
|
||
_shutdown = True
|
||
|
||
|
||
sig_module.signal(sig_module.SIGINT, signal_handler)
|
||
sig_module.signal(sig_module.SIGTERM, signal_handler)
|
||
|
||
|
||
def get_db_conn():
|
||
return psycopg2.connect(
|
||
host=Config.DB_HOST, port=Config.DB_PORT,
|
||
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
|
||
)
|
||
|
||
|
||
def ensure_table(conn):
|
||
"""确保 stock_kline_5min 表存在"""
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
CREATE TABLE IF NOT EXISTS stock_kline_5min (
|
||
code VARCHAR(10) NOT NULL,
|
||
dt TIMESTAMP NOT NULL,
|
||
open DECIMAL(12, 4),
|
||
high DECIMAL(12, 4),
|
||
low DECIMAL(12, 4),
|
||
close DECIMAL(12, 4),
|
||
volume BIGINT,
|
||
amount DECIMAL(20, 2),
|
||
change_pct DECIMAL(8, 4),
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (code, dt)
|
||
)
|
||
""")
|
||
cur.execute("CREATE INDEX IF NOT EXISTS idx_kline_5min_dt ON stock_kline_5min(dt)")
|
||
conn.commit()
|
||
|
||
|
||
def get_stock_codes(conn, only_codes=None):
|
||
"""获取需要采集的股票列表"""
|
||
with conn.cursor() as cur:
|
||
if only_codes:
|
||
placeholders = ','.join(['%s'] * len(only_codes))
|
||
cur.execute(f"SELECT code, name FROM stock_realtime_price WHERE code IN ({placeholders}) ORDER BY code",
|
||
only_codes)
|
||
else:
|
||
# 获取所有有效股票(价格>0的)
|
||
cur.execute("""
|
||
SELECT code, name FROM stock_realtime_price
|
||
WHERE price > 0 AND code NOT LIKE 'BJ%%'
|
||
ORDER BY code
|
||
""")
|
||
return cur.fetchall()
|
||
|
||
|
||
def get_already_synced_codes(conn, target_date):
|
||
"""获取今天已经同步过的股票(用于断点续传)"""
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
SELECT DISTINCT code FROM stock_kline_5min
|
||
WHERE dt::date = %s
|
||
""", (target_date,))
|
||
return {r[0] for r in cur.fetchall()}
|
||
|
||
|
||
def _code_to_sina_symbol(code):
|
||
"""股票代码转新浪格式:000001 → sz000001, 600519 → sh600519"""
|
||
if code.startswith(('0', '3')):
|
||
return f'sz{code}'
|
||
elif code.startswith(('6', '5')):
|
||
return f'sh{code}'
|
||
elif code.startswith(('8', '9', '4')):
|
||
return f'bj{code}'
|
||
return f'sz{code}'
|
||
|
||
|
||
def fetch_5min_kline_sina(code):
|
||
"""
|
||
数据源1:新浪API(akshare stock_zh_a_minute)
|
||
优点:稳定、不易被封、回溯约2个月
|
||
返回: list of tuple (code, dt, open, high, low, close, volume, amount, change_pct)
|
||
返回 None 表示无数据(非错误)
|
||
返回 'error' 字符串表示API错误
|
||
"""
|
||
import akshare as ak
|
||
|
||
for attempt in range(MAX_RETRIES + 1):
|
||
try:
|
||
symbol = _code_to_sina_symbol(code)
|
||
df = ak.stock_zh_a_minute(symbol=symbol, period=PERIOD)
|
||
|
||
if df is None or df.empty:
|
||
return None # 无数据,非错误
|
||
|
||
rows = []
|
||
for _, row in df.iterrows():
|
||
dt_str = str(row.get('day', ''))
|
||
try:
|
||
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||
except ValueError:
|
||
continue
|
||
|
||
o = float(row.get('open', 0))
|
||
h = float(row.get('high', 0))
|
||
l = float(row.get('low', 0))
|
||
c = float(row.get('close', 0))
|
||
v = int(float(row.get('volume', 0)))
|
||
amt = float(row.get('amount', 0))
|
||
|
||
rows.append((code, dt, o, h, l, c, v, amt, 0.0))
|
||
return rows if rows else None
|
||
|
||
except (IndexError, KeyError, ValueError):
|
||
# list index out of range / KeyError / ValueError
|
||
# 这些是"该股票无5分钟数据"的表现,不是API故障
|
||
return None # 无数据,非错误
|
||
|
||
except Exception as e:
|
||
err_str = str(e)
|
||
if attempt < MAX_RETRIES:
|
||
wait = RETRY_DELAY * (attempt + 1)
|
||
print(f" ⚠️ {code}(新浪) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True)
|
||
time.sleep(wait)
|
||
continue
|
||
return 'error' # 真正的API错误
|
||
|
||
return 'error'
|
||
|
||
|
||
def fetch_5min_kline_em(code, start_date=None, end_date=None):
|
||
"""
|
||
数据源2:东方财富API(akshare stock_zh_a_hist_min_em)
|
||
优点:有成交额和涨跌幅,回溯约2个月
|
||
缺点:容易被限流
|
||
返回: list of tuple 或 None(无数据) 或 'error'(API错误)
|
||
"""
|
||
import akshare as ak
|
||
|
||
for attempt in range(MAX_RETRIES + 1):
|
||
try:
|
||
kwargs = {'symbol': code, 'period': PERIOD, 'adjust': ''}
|
||
if start_date:
|
||
kwargs['start_date'] = start_date
|
||
if end_date:
|
||
kwargs['end_date'] = end_date
|
||
|
||
df = ak.stock_zh_a_hist_min_em(**kwargs)
|
||
if df is None or df.empty:
|
||
return None
|
||
|
||
rows = []
|
||
for _, row in df.iterrows():
|
||
dt_str = str(row['时间'])
|
||
try:
|
||
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
|
||
except ValueError:
|
||
continue
|
||
|
||
rows.append((
|
||
code, dt,
|
||
float(row.get('开盘', 0)),
|
||
float(row.get('最高', 0)),
|
||
float(row.get('最低', 0)),
|
||
float(row.get('收盘', 0)),
|
||
int(float(row.get('成交量', 0))),
|
||
float(row.get('成交额', 0)),
|
||
float(row.get('涨跌幅', 0)),
|
||
))
|
||
return rows if rows else None
|
||
|
||
except (IndexError, KeyError, ValueError):
|
||
return None # 无数据,非错误
|
||
|
||
except Exception as e:
|
||
err_str = str(e)
|
||
# 连接被断开、限流等属于真正的API错误
|
||
if attempt < MAX_RETRIES:
|
||
wait = RETRY_DELAY * (attempt + 1) * 2 # 东财限流严重,加长等待
|
||
print(f" ⚠️ {code}(东财) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True)
|
||
time.sleep(wait)
|
||
continue
|
||
return 'error'
|
||
|
||
return 'error'
|
||
|
||
|
||
def fetch_5min_kline_tencent(code):
|
||
"""
|
||
数据源3:腾讯财经1分钟数据 → 聚合为5分钟K线
|
||
优点:腾讯云服务器永不被封
|
||
缺点:只有当天数据
|
||
返回: list of tuple 或 None 或 'error'
|
||
"""
|
||
import requests as _requests
|
||
import json as _json
|
||
|
||
try:
|
||
symbol = _code_to_sina_symbol(code) # sh/sz 格式通用
|
||
url = f"https://web.ifzq.gtimg.cn/appstock/app/minute/query?code={symbol}"
|
||
r = _requests.get(url, timeout=15, headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Referer": "https://stockapp.finance.qq.com",
|
||
})
|
||
if r.status_code != 200:
|
||
return 'error'
|
||
|
||
text = r.text
|
||
start_idx = text.find("=") + 1
|
||
end_idx = text.rfind("}") + 1
|
||
data = _json.loads(text[start_idx:end_idx])
|
||
records = data.get("data", {}).get(symbol, {}).get("data", {}).get("data", [])
|
||
if not records:
|
||
return None
|
||
|
||
today = date.today()
|
||
|
||
# 解析1分钟数据: "0930 1466.99 153 22444946.66"
|
||
# 格式: HHMM price volume amount(累积)
|
||
min_data = []
|
||
for rec in records:
|
||
parts = rec.split()
|
||
if len(parts) < 4:
|
||
continue
|
||
hhmm = parts[0]
|
||
price = float(parts[1])
|
||
vol = int(parts[2])
|
||
try:
|
||
h, m = int(hhmm[:2]), int(hhmm[2:])
|
||
dt = datetime(today.year, today.month, today.day, h, m, 0)
|
||
except (ValueError, IndexError):
|
||
continue
|
||
amt = float(parts[3]) if len(parts) > 3 else 0.0
|
||
min_data.append((dt, price, vol, amt))
|
||
|
||
if not min_data:
|
||
return None
|
||
|
||
# 聚合为5分钟K线
|
||
# 5分钟窗口: 09:30-09:35, 09:35-09:40, ...
|
||
from collections import defaultdict
|
||
bars = defaultdict(list)
|
||
for dt, price, vol, amt in min_data:
|
||
# 5分钟窗口起始时间
|
||
minute = dt.minute
|
||
bar_min = (minute // 5) * 5
|
||
bar_dt = dt.replace(minute=bar_min, second=0)
|
||
bars[bar_dt].append((price, vol, amt))
|
||
|
||
rows = []
|
||
prev_vol = 0
|
||
prev_amt = 0
|
||
for bar_dt in sorted(bars.keys()):
|
||
ticks = bars[bar_dt]
|
||
o = ticks[0][0] # 第一个价格
|
||
c = ticks[-1][0] # 最后一个价格
|
||
h = max(p for p, _, _ in ticks)
|
||
l = min(p for p, _, _ in ticks)
|
||
# 腾讯的volume和amount是累积值,取窗口最后的 - 前一窗口最后的
|
||
last_vol = ticks[-1][1]
|
||
bar_vol = last_vol - prev_vol if prev_vol > 0 else ticks[-1][1]
|
||
prev_vol = last_vol
|
||
last_amt = ticks[-1][2]
|
||
bar_amt = last_amt - prev_amt if prev_amt > 0 else ticks[-1][2]
|
||
prev_amt = last_amt
|
||
rows.append((code, bar_dt, o, h, l, c, max(0, bar_vol), max(0, bar_amt), 0.0))
|
||
|
||
return rows if rows else None
|
||
|
||
except (IndexError, KeyError, ValueError):
|
||
return None
|
||
except Exception:
|
||
return 'error'
|
||
|
||
|
||
def fetch_5min_kline(code, start_date=None, end_date=None):
|
||
"""
|
||
主入口:优先东财API → 新浪API → 腾讯聚合(仅当天)
|
||
返回:
|
||
- list of tuple: 成功获取数据
|
||
- None: 该股票无5分钟数据(停牌、退市等,非错误)
|
||
- 'error': API故障/限流
|
||
"""
|
||
global _em_consecutive_errors, _em_disabled
|
||
|
||
# 1) 优先使用东财API(数据最全)
|
||
with _em_lock:
|
||
em_ok = not _em_disabled
|
||
|
||
if em_ok:
|
||
result = fetch_5min_kline_em(code, start_date, end_date)
|
||
if result == 'error':
|
||
with _em_lock:
|
||
_em_consecutive_errors += 1
|
||
if _em_consecutive_errors >= EM_CIRCUIT_BREAKER:
|
||
_em_disabled = True
|
||
print(f" ⚠️ 东财API连续失败{EM_CIRCUIT_BREAKER}次,尝试备用源", flush=True)
|
||
time.sleep(2)
|
||
else:
|
||
with _em_lock:
|
||
_em_consecutive_errors = 0
|
||
return result
|
||
|
||
# 2) 备用:新浪API
|
||
result = fetch_5min_kline_sina(code)
|
||
if result is not None and result != 'error':
|
||
return result
|
||
|
||
# 3) 终极备用:腾讯1分钟聚合(仅当天数据,但永不被封)
|
||
return fetch_5min_kline_tencent(code)
|
||
|
||
|
||
def save_batch(conn, all_rows):
|
||
"""批量保存5分钟K线数据(UPSERT)"""
|
||
if not all_rows:
|
||
return 0
|
||
|
||
with conn.cursor() as cur:
|
||
execute_values(
|
||
cur,
|
||
"""
|
||
INSERT INTO stock_kline_5min (code, dt, open, high, low, close, volume, amount, change_pct)
|
||
VALUES %s
|
||
ON CONFLICT (code, dt) DO UPDATE SET
|
||
open = EXCLUDED.open,
|
||
high = EXCLUDED.high,
|
||
low = EXCLUDED.low,
|
||
close = EXCLUDED.close,
|
||
volume = EXCLUDED.volume,
|
||
amount = EXCLUDED.amount,
|
||
change_pct = EXCLUDED.change_pct,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""",
|
||
all_rows,
|
||
page_size=1000,
|
||
)
|
||
conn.commit()
|
||
return len(all_rows)
|
||
|
||
|
||
def filter_rows_by_date(rows, target_date):
|
||
"""只保留目标日期的行"""
|
||
if not rows:
|
||
return None
|
||
filtered = [r for r in rows if r[1].date() == target_date]
|
||
return filtered if filtered else None
|
||
|
||
|
||
def _probe_apis():
|
||
"""启动时探测各API是否可用,提前设置熔断状态"""
|
||
global _em_disabled, _em_consecutive_errors
|
||
print("🔍 探测数据源可用性...", flush=True)
|
||
|
||
# 测试东财API(用最活跃的股票)
|
||
em_ok = False
|
||
try:
|
||
import akshare as ak
|
||
df = ak.stock_zh_a_hist_min_em(symbol='600519', period='5', adjust='')
|
||
if df is not None and not df.empty:
|
||
em_ok = True
|
||
print(" ✅ 东财API: 可用", flush=True)
|
||
else:
|
||
print(" ❌ 东财API: 返回空数据", flush=True)
|
||
except Exception as e:
|
||
print(f" ❌ 东财API: {str(e)[:60]}", flush=True)
|
||
|
||
if not em_ok:
|
||
with _em_lock:
|
||
_em_disabled = True
|
||
_em_consecutive_errors = EM_CIRCUIT_BREAKER
|
||
print(" → 东财API已禁用,将使用备用源", flush=True)
|
||
|
||
# 测试新浪API
|
||
sina_ok = False
|
||
try:
|
||
result = fetch_5min_kline_sina('600519')
|
||
if result is not None and result != 'error':
|
||
sina_ok = True
|
||
print(" ✅ 新浪API: 可用", flush=True)
|
||
else:
|
||
print(" ❌ 新浪API: 不可用", flush=True)
|
||
except Exception:
|
||
print(" ❌ 新浪API: 异常", flush=True)
|
||
|
||
# 腾讯API(聚合方式)总是可用
|
||
print(" ✅ 腾讯API: 始终可用(聚合1分钟→5分钟)", flush=True)
|
||
|
||
source = "东财" if em_ok else ("新浪" if sina_ok else "腾讯(聚合)")
|
||
print(f" 📡 主数据源: {source}", flush=True)
|
||
return em_ok, sina_ok
|
||
|
||
|
||
def main():
|
||
global _shutdown
|
||
|
||
# 进程锁 — 防止多实例同时运行
|
||
if not acquire_lock():
|
||
sys.exit(1)
|
||
|
||
parser = argparse.ArgumentParser(description='5分钟K线数据采集')
|
||
parser.add_argument('--backfill', type=int, default=0, metavar='DAYS',
|
||
help='回填最近N天的数据(默认0=只采集当天)')
|
||
parser.add_argument('--codes', type=str, default=None,
|
||
help='只采集指定股票(逗号分隔,如 300720,000001)')
|
||
parser.add_argument('--limit', type=int, default=0,
|
||
help='限制采集股票数量(0=全部,用于测试)')
|
||
parser.add_argument('--delay', type=float, default=API_DELAY,
|
||
help=f'API调用间隔秒数(默认{API_DELAY})')
|
||
parser.add_argument('--resume', action='store_true',
|
||
help='跳过今天已采集的股票(断点续传)')
|
||
parser.add_argument('--workers', type=int, default=DEFAULT_WORKERS,
|
||
help=f'并发线程数(默认{DEFAULT_WORKERS})')
|
||
args = parser.parse_args()
|
||
|
||
today = date.today()
|
||
target_dates = [today]
|
||
if args.backfill > 0:
|
||
for i in range(1, args.backfill + 1):
|
||
d = today - timedelta(days=i)
|
||
if d.weekday() < 5: # 跳过周末
|
||
target_dates.append(d)
|
||
target_dates.sort()
|
||
|
||
only_codes = args.codes.split(',') if args.codes else None
|
||
|
||
print(f"{'='*70}", flush=True)
|
||
print(f"📊 5分钟K线数据采集(并发模式)", flush=True)
|
||
print(f"📅 目标日期: {', '.join(str(d) for d in target_dates)}", flush=True)
|
||
print(f"⏱️ API间隔: {args.delay}s | 并发: {args.workers} 线程", flush=True)
|
||
if only_codes:
|
||
print(f"🎯 指定股票: {only_codes}", flush=True)
|
||
print(f"{'='*70}", flush=True)
|
||
|
||
# 启动时探测API可用性,提前熔断不可用的源
|
||
em_ok, sina_ok = _probe_apis()
|
||
# 如果主源不可用,腾讯聚合模式可以用更多并发(不限流)
|
||
if not em_ok and not sina_ok:
|
||
if args.workers < 5:
|
||
args.workers = 5
|
||
print(f" 📡 腾讯模式:提升并发到 {args.workers} 线程", flush=True)
|
||
if args.delay > 0.3:
|
||
args.delay = 0.3
|
||
print(f" 📡 腾讯模式:降低延迟到 {args.delay}s", flush=True)
|
||
|
||
conn = get_db_conn()
|
||
ensure_table(conn)
|
||
|
||
all_stocks = get_stock_codes(conn, only_codes)
|
||
if args.limit > 0:
|
||
all_stocks = all_stocks[:args.limit]
|
||
total = len(all_stocks)
|
||
print(f"📈 待采集股票: {total} 只", flush=True)
|
||
|
||
if args.resume:
|
||
synced = get_already_synced_codes(conn, today)
|
||
before = len(all_stocks)
|
||
all_stocks = [(c, n) for c, n in all_stocks if c not in synced]
|
||
print(f"🔄 断点续传: 跳过 {before - len(all_stocks)} 只已同步, 剩余 {len(all_stocks)} 只", flush=True)
|
||
total = len(all_stocks)
|
||
|
||
start_time = time.time()
|
||
done_count = 0
|
||
success_count = 0
|
||
error_count = 0
|
||
skip_count = 0
|
||
total_rows = 0
|
||
save_buffer = []
|
||
last_report_time = time.time()
|
||
|
||
# 线程安全锁
|
||
_stats_lock = threading.Lock()
|
||
_buffer_lock = threading.Lock()
|
||
|
||
def _fetch_one(code_name):
|
||
"""单只股票采集任务(在工作线程中运行)"""
|
||
code, name = code_name
|
||
if _shutdown:
|
||
return None
|
||
# 线程内延迟,分散API请求
|
||
time.sleep(args.delay)
|
||
result = fetch_5min_kline(code)
|
||
return (code, name, result)
|
||
|
||
print(f"\n🚀 开始采集({args.workers}线程并发)...", flush=True)
|
||
print(f"-" * 70, flush=True)
|
||
|
||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||
futures = {executor.submit(_fetch_one, item): item for item in all_stocks}
|
||
|
||
for future in as_completed(futures):
|
||
if _shutdown:
|
||
break
|
||
|
||
ret = future.result()
|
||
if ret is None:
|
||
continue
|
||
|
||
code, name, result = ret
|
||
|
||
with _stats_lock:
|
||
done_count += 1
|
||
|
||
if isinstance(result, list):
|
||
rows = result
|
||
if args.backfill == 0:
|
||
rows = filter_rows_by_date(rows, today)
|
||
|
||
if rows:
|
||
with _buffer_lock:
|
||
save_buffer.extend(rows)
|
||
success_count += 1
|
||
else:
|
||
skip_count += 1
|
||
elif result == 'error':
|
||
error_count += 1
|
||
else:
|
||
skip_count += 1
|
||
|
||
# 攒够一批就保存
|
||
with _buffer_lock:
|
||
if len(save_buffer) >= BATCH_SAVE_SIZE:
|
||
saved = save_batch(conn, save_buffer)
|
||
total_rows += saved
|
||
save_buffer = []
|
||
|
||
# 进度报告
|
||
now = time.time()
|
||
if now - last_report_time >= 5:
|
||
elapsed = now - start_time
|
||
speed = done_count / elapsed if elapsed > 0 else 0
|
||
remaining = (total - done_count) / speed if speed > 0 else 0
|
||
pct = done_count / total * 100 if total > 0 else 100
|
||
print(f" [{pct:5.1f}%] {done_count}/{total} "
|
||
f"| {speed:.1f}只/秒 | 剩余 {remaining/60:.1f}分钟 "
|
||
f"| ✅{success_count} ❌{error_count} ⏭️{skip_count} "
|
||
f"| 已保存 {total_rows}条", flush=True)
|
||
last_report_time = now
|
||
|
||
# 保存剩余数据
|
||
if save_buffer:
|
||
saved = save_batch(conn, save_buffer)
|
||
total_rows += saved
|
||
|
||
elapsed = time.time() - start_time
|
||
speed = done_count / elapsed if elapsed > 0 else 0
|
||
|
||
print(f"\n{'='*70}", flush=True)
|
||
print(f"{'⏹️ 中断' if _shutdown else '✅ 完成'}!", flush=True)
|
||
print(f" 处理: {done_count}/{total} 只", flush=True)
|
||
print(f" 成功: {success_count} | 失败: {error_count} | 跳过: {skip_count}", flush=True)
|
||
print(f" 保存: {total_rows} 条 5分钟K线", flush=True)
|
||
print(f" 耗时: {elapsed/60:.1f} 分钟 ({speed:.1f} 只/秒)", flush=True)
|
||
|
||
# 显示数据库统计
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
SELECT count(*), count(DISTINCT code),
|
||
min(dt::date), max(dt::date),
|
||
count(DISTINCT dt::date)
|
||
FROM stock_kline_5min
|
||
""")
|
||
cnt, codes, min_d, max_d, days = cur.fetchone()
|
||
print(f"\n📊 stock_kline_5min 数据库统计:", flush=True)
|
||
print(f" 总记录: {cnt:,} 条 | 覆盖: {codes} 只股票 | {days} 天", flush=True)
|
||
print(f" 日期: {min_d} ~ {max_d}", flush=True)
|
||
|
||
# 按日期统计
|
||
cur.execute("""
|
||
SELECT dt::date AS trade_date, count(*), count(DISTINCT code)
|
||
FROM stock_kline_5min
|
||
GROUP BY dt::date
|
||
ORDER BY dt::date DESC
|
||
LIMIT 5
|
||
""")
|
||
print(f" 最近5天:", flush=True)
|
||
for d, cnt, codes in cur.fetchall():
|
||
print(f" {d}: {cnt:>8,} 条 ({codes} 只股票)", flush=True)
|
||
|
||
print(f"{'='*70}", flush=True)
|
||
conn.close()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|