Files
stock/stock-html/stock_data_service.py
T
freedakgmail 9c7d7abdd4 Initial commit
2026-07-17 18:49:35 +08:00

575 lines
21 KiB
Python

#!/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()