Files
stock/stock-html/sync_fund_flow.py
selfrelease 2b5a32ca1e feat: 新增外部因素分析模块+综合评分引擎+算法文档重构
新增模块:
- fund_flow_analyzer.py: 主力资金流向分析(P0, ±20)
- market_sentiment.py: 市场情绪指标(P1, ±10)
- external_factors.py: 北向资金/美股/大宗商品/汇率(P2-P4,P7)
- news_analyzer.py: 公告/并购/政策面LLM分析(P5-P6)
- score_engine.py: 综合评分引擎,整合技术面+外部因素

路由更新:
- analysis.py: deep_analyze接入综合评分,根据最终评级修正买卖建议
- market.py: 新增4个外部因素API端点
- trades.py: 交易路由更新

算法文档重构:
- 章节重排: 技术面(二三)→外部因素(四)→买卖决策(五)→数据源(六)→性能(七)
- 架构图更新为五层,标注章节对应
- 5.1/5.2标注纯技术面,5.3整合外部因素修正推荐
2026-07-17 23:50:42 +08:00

391 lines
14 KiB
Python
Executable File
Raw Permalink 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.
#!/usr/bin/env python3
"""
资金流向数据每日采集脚本
功能:从5分钟K线数据自行计算资金流向,存入 stock_fund_flow_history 和 stock_fund_flow_today 表。
数据源:stock_kline_5min 表(自有数据,无需外部API)
算法:
- 根据5分钟K线的 close vs open 判断买卖方向
- 根据成交额(amount)分类:超大单(≥100万), 大单(20~100万), 中单(4~20万), 小单(<4万)
- 主力 = 超大单 + 大单
用法:
# 计算今日资金流向
./venv/bin/python sync_fund_flow.py
# 补算历史(有5分钟K线但尚无资金流向的日期,最多30天)
./venv/bin/python sync_fund_flow.py --backfill
建议定时任务:
10 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py >> /opt/stock-app/sync_fund_flow.log 2>&1
30 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py --backfill >> /opt/stock-app/sync_fund_flow.log 2>&1
"""
import sys
import os
import time
import argparse
import fcntl
import atexit
from datetime import datetime, date
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import psycopg2
from psycopg2.extras import execute_values
from config import Config
LOCK_FILE = '/tmp/sync_fund_flow.lock'
_lock_fd = None
def acquire_lock():
"""获取进程锁,防止多实例同时运行"""
global _lock_fd
_lock_fd = open(LOCK_FILE, 'w')
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:
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 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 calc_fund_flow_from_5min(conn, target_date):
"""
从5分钟K线数据计算某日资金流向
算法:
1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出)
2. 根据成交额(amount)分类:
- 超大单: amount >= 100万
- 大单: 20万 <= amount < 100万
- 中单: 4万 <= amount < 20万
- 小单: amount < 4万
3. 主力 = 超大单 + 大单
"""
cur = conn.cursor()
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 {}
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
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_today_flow(conn):
"""更新今日资金流向到 stock_fund_flow_today"""
today = date.today()
flows = calc_fund_flow_from_5min(conn, today)
if not flows:
print(f" 今日({today})无5分钟K线数据,跳过", flush=True)
return 0
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 = {r[0]: {'name': r[1], 'price': float(r[2] or 0), 'change_pct': float(r[3] or 0)}
for r in cur.fetchall()}
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()
print(f" ✅ 今日资金流向: {len(records)} 只股票", flush=True)
return len(records)
def backfill_history(conn, max_days=30):
"""补算历史资金流向: 有5分钟K线但尚无 fund_flow_history 的日期"""
cur = conn.cursor()
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 %s
""", (max_days,))
missing_dates = [row[0] for row in cur.fetchall()]
if not missing_dates:
print(" ✅ 历史资金流向已完整,无需补算", flush=True)
return 0
print(f" 需补算 {len(missing_dates)} 天的历史资金流向", flush=True)
total_records = 0
for d in missing_dates:
flows = calc_fund_flow_from_5min(conn, d)
if not flows:
continue
# 获取当天收盘价和涨跌幅(通过前一日收盘价计算)
cur.execute("""
SELECT k.code, k.close,
CASE WHEN prev.close > 0
THEN ROUND((k.close - prev.close) / prev.close * 100, 2)
ELSE 0 END AS change_pct
FROM stock_kline_daily k
LEFT JOIN LATERAL (
SELECT close FROM stock_kline_daily
WHERE code = k.code AND trade_date < k.trade_date
ORDER BY trade_date DESC LIMIT 1
) prev ON true
WHERE k.trade_date = %s AND k.code = ANY(%s)
""", (d, list(flows.keys())))
price_map = {r[0]: {'close': float(r[1] or 0), 'change_pct': float(r[2] or 0)}
for r in cur.fetchall()}
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)
print(f" {d}: {len(records)} 只股票", flush=True)
print(f" ✅ 历史补算完成: {total_records} 条记录, {len(missing_dates)}", flush=True)
return total_records
def main():
if not acquire_lock():
sys.exit(1)
parser = argparse.ArgumentParser(description='资金流向计算(从5分钟K线数据)')
parser.add_argument('--backfill', action='store_true',
help='补算历史资金流向(有5分钟K线但尚无资金流向的日期)')
parser.add_argument('--max-days', type=int, default=30,
help='历史补算最大天数(默认30')
args = parser.parse_args()
today = date.today()
print(f"{'='*60}", flush=True)
print(f"💰 资金流向计算(来源: 5分钟K线数据)", flush=True)
print(f"📅 日期: {today}", flush=True)
print(f"{'='*60}", flush=True)
conn = get_db_conn()
start_time = time.time()
try:
# 始终计算今日
print("\n📊 计算今日资金流向...", flush=True)
today_count = update_today_flow(conn)
# 如果指定了 --backfill,补算历史
if args.backfill:
print(f"\n📜 补算历史资金流向(最多{args.max_days}天)...", flush=True)
hist_count = backfill_history(conn, args.max_days)
else:
hist_count = 0
elapsed = time.time() - start_time
# 显示数据库统计
cur = conn.cursor()
cur.execute("""
SELECT count(*), count(DISTINCT code),
min(trade_date), max(trade_date),
count(DISTINCT trade_date)
FROM stock_fund_flow_history
""")
cnt, codes, min_d, max_d, days = cur.fetchone()
print(f"\n{'='*60}", flush=True)
print(f"✅ 完成! 耗时: {elapsed:.1f}", flush=True)
print(f" 今日: {today_count} 条 | 历史补算: {hist_count}", flush=True)
print(f"\n💰 stock_fund_flow_history 统计:", flush=True)
print(f" 总记录: {cnt:,} 条 | {codes:,} 只股票 | {days} 个交易日", flush=True)
if min_d:
print(f" 日期范围: {min_d} ~ {max_d}", flush=True)
print(f"{'='*60}", flush=True)
except Exception as e:
print(f"❌ 错误: {e}", flush=True)
import traceback
traceback.print_exc()
finally:
conn.close()
if __name__ == '__main__':
main()