#!/usr/bin/env python3 """ 股票数据采集服务 定时采集实时行情数据并存入数据库 数据源: 腾讯财经 (qt.gtimg.cn) """ import os import sys import time import logging import schedule import psycopg2 from psycopg2.extras import execute_values from datetime import datetime # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.StreamHandler(), logging.FileHandler('stock_data_service.log') ] ) logger = logging.getLogger(__name__) # 数据库配置 DB_CONFIG = { 'host': os.environ.get('DB_HOST', 'localhost'), 'port': int(os.environ.get('DB_PORT', 5432)), 'database': os.environ.get('DB_NAME', 'stock_app'), 'user': os.environ.get('DB_USER', 'postgres'), 'password': os.environ.get('DB_PASSWORD', '') } def get_db(): """获取数据库连接""" try: return psycopg2.connect(**DB_CONFIG) except Exception as e: logger.error(f"数据库连接失败: {e}") return None def log_update(data_type, status, records_count=0, error_message=None, started_at=None): """记录更新日志""" conn = get_db() if not conn: return try: cur = conn.cursor() cur.execute(""" INSERT INTO data_update_log (data_type, status, records_count, error_message, started_at) VALUES (%s, %s, %s, %s, %s) """, (data_type, status, records_count, error_message, started_at)) conn.commit() except Exception as e: logger.error(f"记录日志失败: {e}") finally: conn.close() def _to_tencent_code(code): """将纯数字股票代码转为腾讯格式 (sh/sz/bj前缀)""" if code.startswith('6'): return f'sh{code}' elif code.startswith('0') or code.startswith('3'): return f'sz{code}' elif code.startswith('8') or code.startswith('4'): return f'bj{code}' else: return f'sz{code}' def _fetch_realtime_from_tencent(stock_codes): """ 从腾讯财经API批量获取实时行情(最佳数据源,腾讯云极快) 腾讯API字段(88个)关键映射: [1]=名称 [2]=代码 [3]=现价 [4]=昨收 [5]=开盘 [6]=成交量(手) [31]=涨跌额 [32]=涨跌% [33]=最高 [34]=最低 [37]=成交额(万) [39]=市盈率 [45]=总市值(亿) [46]=市净率 """ import requests import math def safe_float(val, default=0): try: if val is None or val == '' or val == ' ': return default f = float(val) return default if math.isnan(f) else f except: return default logger.info(f" 使用腾讯财经数据源 ({len(stock_codes)} 只股票)...") tencent_codes = [_to_tencent_code(c) for c in stock_codes] records = [] batch_size = 80 errors = 0 for i in range(0, len(tencent_codes), batch_size): batch = tencent_codes[i:i+batch_size] url = f"http://qt.gtimg.cn/q={','.join(batch)}" try: r = requests.get(url, timeout=15, headers={'Referer': 'https://finance.qq.com'}) if r.status_code != 200: errors += 1 continue lines = r.text.strip().split(';') for line in lines: if '\"' not in line: continue data = line.split('\"')[1] fields = data.split('~') if len(fields) < 40 or not fields[3]: continue code = fields[2] price = safe_float(fields[3]) if price <= 0: continue # 成交量: 腾讯API返回的是手(1手=100股) volume_hands = safe_float(fields[6]) volume = int(volume_hands * 100) # 成交额: 万元 -> 元 amount = safe_float(fields[37]) * 10000 # 总市值: 亿元 -> 元 total_market_cap_yi = safe_float(fields[45]) if len(fields) > 45 else 0 total_market_cap = total_market_cap_yi * 100000000 if total_market_cap_yi > 0 else None records.append(( code, # 代码 fields[1], # 名称 price, # 现价 safe_float(fields[32]), # 涨跌% safe_float(fields[31]), # 涨跌额 volume, # 成交量(股) amount, # 成交额(元) safe_float(fields[33]), # 最高 safe_float(fields[34]), # 最低 safe_float(fields[5]), # 开盘 safe_float(fields[4]), # 昨收 safe_float(fields[39]) if len(fields) > 39 and fields[39].strip() else None, # PE safe_float(fields[46]) if len(fields) > 46 and fields[46].strip() else None, # PB total_market_cap, # 总市值 )) except Exception as e: errors += 1 if errors <= 3: logger.warning(f" 腾讯API批次 {i//batch_size+1} 失败: {e}") import time time.sleep(0.1) # 控制请求频率 if errors > 0: logger.warning(f" 腾讯API共 {errors} 个批次失败") return records if records else None, 'tencent' def update_realtime_prices(): """更新实时价格(全市场A股)- 使用腾讯财经数据源""" started_at = datetime.now() logger.info("开始更新实时价格...") conn = get_db() if not conn: log_update('realtime_price', 'failed', 0, '数据库连接失败', started_at) return try: # 先从DB获取已有的股票代码列表 cur = conn.cursor() cur.execute("SELECT code FROM stock_realtime_price") existing_codes = [r[0] for r in cur.fetchall()] records = None source = None # 腾讯财经数据源 if existing_codes: try: result = _fetch_realtime_from_tencent(existing_codes) if result and result[0]: records, source = result except Exception as e: logger.warning(f" 腾讯数据源失败: {e}") if not records: log_update('realtime_price', 'failed', 0, '所有数据源均失败', started_at) return logger.info(f" 数据源={source}, 获取 {len(records)} 条记录") # 批量插入/更新 cur = conn.cursor() execute_values(cur, """ INSERT INTO stock_realtime_price (code, name, price, change_pct, change_amount, volume, amount, high, low, open, prev_close, pe, pb, total_market_cap, updated_at) VALUES %s ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, change_pct = EXCLUDED.change_pct, change_amount = EXCLUDED.change_amount, volume = EXCLUDED.volume, amount = EXCLUDED.amount, high = EXCLUDED.high, low = EXCLUDED.low, open = EXCLUDED.open, prev_close = EXCLUDED.prev_close, pe = COALESCE(EXCLUDED.pe, stock_realtime_price.pe), pb = COALESCE(EXCLUDED.pb, stock_realtime_price.pb), total_market_cap = COALESCE(EXCLUDED.total_market_cap, stock_realtime_price.total_market_cap), updated_at = NOW() """, records, template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") conn.commit() logger.info(f"实时价格更新完成({source}): {len(records)} 条") log_update('realtime_price', 'success', len(records), f'source={source}', started_at) except Exception as e: logger.error(f"更新实时价格失败: {e}") log_update('realtime_price', 'failed', 0, str(e), started_at) finally: conn.close() def _calc_fund_flow_from_5min(conn, target_date=None): """ 从5分钟K线数据计算资金流向(替代东方财富API) 算法: 1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出) 2. 根据成交额(amount)分类: - 超大单: amount >= 100万 - 大单: 20万 <= amount < 100万 - 中单: 4万 <= amount < 20万 - 小单: amount < 4万 3. 主力 = 超大单 + 大单 4. 聚合每只股票的各类净流入 """ from datetime import date as date_cls if target_date is None: target_date = date_cls.today() cur = conn.cursor() # 获取当天所有5分钟K线数据 cur.execute(""" SELECT code, open, close, volume, amount FROM stock_kline_5min WHERE dt::date = %s AND amount > 0 ORDER BY code, dt """, (target_date,)) rows = cur.fetchall() if not rows: return {} # 按股票聚合 from collections import defaultdict stock_flows = defaultdict(lambda: { 'super_buy': 0, 'super_sell': 0, 'big_buy': 0, 'big_sell': 0, 'mid_buy': 0, 'mid_sell': 0, 'small_buy': 0, 'small_sell': 0, 'total_amount': 0 }) for code, open_p, close_p, volume, amount in rows: if not amount or float(amount) <= 0: continue amt = float(amount) sf = stock_flows[code] sf['total_amount'] += amt # 方向: close > open 视为买入, close < open 视为卖出, 相等则各半 is_buy = float(close_p) >= float(open_p) if close_p and open_p else True # 分类 if amt >= 1000000: # 超大单 >= 100万 cat = 'super' elif amt >= 200000: # 大单 >= 20万 cat = 'big' elif amt >= 40000: # 中单 >= 4万 cat = 'mid' else: # 小单 cat = 'small' if is_buy: sf[f'{cat}_buy'] += amt else: sf[f'{cat}_sell'] += amt # 计算各类净流入和占比 results = {} for code, sf in stock_flows.items(): total = sf['total_amount'] if total <= 0: continue super_net = sf['super_buy'] - sf['super_sell'] big_net = sf['big_buy'] - sf['big_sell'] mid_net = sf['mid_buy'] - sf['mid_sell'] small_net = sf['small_buy'] - sf['small_sell'] main_net = super_net + big_net # 主力 = 超大单 + 大单 results[code] = { 'main_net_inflow': round(main_net, 2), 'main_net_inflow_pct': round(main_net / total * 100, 4) if total > 0 else 0, 'super_net_inflow': round(super_net, 2), 'super_net_inflow_pct': round(super_net / total * 100, 4) if total > 0 else 0, 'big_net_inflow': round(big_net, 2), 'big_net_inflow_pct': round(big_net / total * 100, 4) if total > 0 else 0, 'mid_net_inflow': round(mid_net, 2), 'mid_net_inflow_pct': round(mid_net / total * 100, 4) if total > 0 else 0, 'small_net_inflow': round(small_net, 2), 'small_net_inflow_pct': round(small_net / total * 100, 4) if total > 0 else 0, } return results def update_fund_flow_today(): """更新今日资金流向 — 从5分钟K线数据自行计算""" started_at = datetime.now() logger.info("开始更新今日资金流向(从5分钟K线数据计算)...") conn = get_db() if not conn: log_update('fund_flow_today', 'failed', 0, '数据库连接失败', started_at) return try: flows = _calc_fund_flow_from_5min(conn) if not flows: logger.info("今日资金流向: 无5分钟K线数据,跳过") log_update('fund_flow_today', 'skipped', 0, '无5分钟K线数据', started_at) return # 获取实时价格和名称 cur = conn.cursor() codes = list(flows.keys()) cur.execute(""" SELECT code, name, price, change_pct FROM stock_realtime_price WHERE code = ANY(%s) """, (codes,)) price_map = {} for row in cur.fetchall(): price_map[row[0]] = {'name': row[1], 'price': float(row[2] or 0), 'change_pct': float(row[3] or 0)} # 批量写入 records = [] for code, f in flows.items(): info = price_map.get(code, {}) records.append(( code, info.get('name', ''), f['main_net_inflow'], f['main_net_inflow_pct'], f['super_net_inflow'], f['super_net_inflow_pct'], f['big_net_inflow'], f['big_net_inflow_pct'], f['mid_net_inflow'], f['mid_net_inflow_pct'], f['small_net_inflow'], f['small_net_inflow_pct'], info.get('price', 0), info.get('change_pct', 0), )) execute_values(cur, """ INSERT INTO stock_fund_flow_today (code, name, main_net_inflow, main_net_inflow_pct, super_net_inflow, super_net_inflow_pct, big_net_inflow, big_net_inflow_pct, mid_net_inflow, mid_net_inflow_pct, small_net_inflow, small_net_inflow_pct, price, change_pct, updated_at) VALUES %s ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, main_net_inflow = EXCLUDED.main_net_inflow, main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, super_net_inflow = EXCLUDED.super_net_inflow, super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, big_net_inflow = EXCLUDED.big_net_inflow, big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, mid_net_inflow = EXCLUDED.mid_net_inflow, mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, small_net_inflow = EXCLUDED.small_net_inflow, small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, price = EXCLUDED.price, change_pct = EXCLUDED.change_pct, updated_at = NOW() """, records, template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") conn.commit() logger.info(f"今日资金流向更新完成: {len(records)} 条 (来源: 5分钟K线计算)") log_update('fund_flow_today', 'success', len(records), '来源: 5分钟K线计算', started_at) except Exception as e: logger.error(f"更新今日资金流向失败: {e}") log_update('fund_flow_today', 'failed', 0, str(e), started_at) finally: conn.close() def update_fund_flow_history(stock_codes=None): """更新历史资金流向 — 从5分钟K线数据自行计算 遍历有5分钟K线但尚未写入fund_flow_history的日期,补算资金流向 """ started_at = datetime.now() logger.info("开始更新历史资金流向(从5分钟K线数据计算)...") conn = get_db() if not conn: log_update('fund_flow_history', 'failed', 0, '数据库连接失败', started_at) return try: cur = conn.cursor() # 查找有5分钟K线数据但尚未计算资金流向的日期 cur.execute(""" SELECT DISTINCT dt::date as d FROM stock_kline_5min WHERE dt::date NOT IN ( SELECT DISTINCT trade_date FROM stock_fund_flow_history ) AND dt::date < CURRENT_DATE ORDER BY d DESC LIMIT 30 """) missing_dates = [row[0] for row in cur.fetchall()] if not missing_dates: logger.info("历史资金流向: 无需补算") log_update('fund_flow_history', 'success', 0, '无需补算', started_at) return logger.info(f"需补算 {len(missing_dates)} 天的历史资金流向") total_records = 0 for d in missing_dates: flows = _calc_fund_flow_from_5min(conn, target_date=d) if not flows: continue # 获取当天收盘价和涨跌幅 cur.execute(""" SELECT code, close, change_pct FROM stock_kline_daily WHERE trade_date = %s AND code = ANY(%s) """, (d, list(flows.keys()))) price_map = {} for row in cur.fetchall(): price_map[row[0]] = {'close': float(row[1] or 0), 'change_pct': float(row[2] or 0)} records = [] for code, f in flows.items(): info = price_map.get(code, {}) records.append(( code, d, info.get('close', 0), info.get('change_pct', 0), f['main_net_inflow'], f['main_net_inflow_pct'], f['super_net_inflow'], f['super_net_inflow_pct'], f['big_net_inflow'], f['big_net_inflow_pct'], f['mid_net_inflow'], f['mid_net_inflow_pct'], f['small_net_inflow'], f['small_net_inflow_pct'], )) if records: execute_values(cur, """ INSERT INTO stock_fund_flow_history (code, trade_date, close_price, change_pct, main_net_inflow, main_net_inflow_pct, super_net_inflow, super_net_inflow_pct, big_net_inflow, big_net_inflow_pct, mid_net_inflow, mid_net_inflow_pct, small_net_inflow, small_net_inflow_pct, updated_at) VALUES %s ON CONFLICT (code, trade_date) DO UPDATE SET close_price = EXCLUDED.close_price, change_pct = EXCLUDED.change_pct, main_net_inflow = EXCLUDED.main_net_inflow, main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, super_net_inflow = EXCLUDED.super_net_inflow, super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, big_net_inflow = EXCLUDED.big_net_inflow, big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, mid_net_inflow = EXCLUDED.mid_net_inflow, mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, small_net_inflow = EXCLUDED.small_net_inflow, small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, updated_at = NOW() """, records, template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") conn.commit() total_records += len(records) logger.info(f" {d}: {len(records)} 只股票") logger.info(f"历史资金流向补算完成: {total_records} 条记录,{len(missing_dates)} 天") log_update('fund_flow_history', 'success', total_records, f'补算{len(missing_dates)}天, 来源: 5分钟K线计算', started_at) except Exception as e: logger.error(f"更新历史资金流向失败: {e}") log_update('fund_flow_history', 'failed', 0, str(e), started_at) finally: conn.close() def is_trading_time(): """检查当前是否为交易时间""" now = datetime.now() # 周一到周五 if now.weekday() >= 5: return False # 9:15-11:35, 12:55-15:05 hour_min = now.hour * 100 + now.minute return (915 <= hour_min <= 1135) or (1255 <= hour_min <= 1505) def run_scheduled_tasks(): """运行定时任务""" logger.info("股票数据采集服务启动...") # 交易时间每5分钟更新实时价格(腾讯财经数据源) schedule.every(5).minutes.do(lambda: update_realtime_prices() if is_trading_time() else None) # 每天18:05更新今日资金流向(从5分钟K线计算,需在5分钟K线采集17:30后) schedule.every().day.at("18:05").do(update_fund_flow_today) # 每天18:15补算历史资金流向(从5分钟K线计算) schedule.every().day.at("18:15").do(update_fund_flow_history) # 立即执行一次 logger.info("首次执行数据更新...") update_realtime_prices() # 主循环 while True: schedule.run_pending() time.sleep(60) def main(): """主函数""" if len(sys.argv) > 1: cmd = sys.argv[1] if cmd == 'realtime': update_realtime_prices() elif cmd == 'fund_today': update_fund_flow_today() elif cmd == 'fund_history': stock_codes = sys.argv[2:] if len(sys.argv) > 2 else None update_fund_flow_history(stock_codes) elif cmd == 'daemon': run_scheduled_tasks() else: print(f"未知命令: {cmd}") print("用法: python stock_data_service.py [realtime|fund_today|fund_history|daemon]") else: # 默认执行一次实时价格更新 update_realtime_prices() if __name__ == '__main__': main()