windows-desktop: C端基线(Windows 本地实时分析器 + 采集器 bug 修复)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# a_stock_timeline
|
||||
@@ -0,0 +1,211 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
A股时间线实证模式挖掘:从 a_stock.db 挖掘隔夜传导/跳空回补/周内效应/
|
||||
波动率机制/龙虎榜统计,输出 markdown 到 E:/Data/skills/a-stock-timeline-patterns/references/
|
||||
口径:上证/沪深300 主样本;隔夜传导以纳斯达克为主参照。
|
||||
"""
|
||||
import sqlite3
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
DB = r'C:' + chr(92) + 'Users' + chr(92) + 'lookt' + chr(92) + 'a_stock_timeline' + chr(92) + 'data' + chr(92) + 'a_stock.db'
|
||||
OUT = r'E:' + chr(92) + 'Data' + chr(92) + 'skills' + chr(92) + 'a-stock-timeline-patterns' + chr(92) + 'references'
|
||||
|
||||
IDX_NAMES = {'sh000001': '上证指数', 'sz399001': '深证成指', 'sh000300': '沪深300',
|
||||
'sz399006': '创业板指', 'sh000688': '科创50'}
|
||||
|
||||
|
||||
def load():
|
||||
conn = sqlite3.connect(DB)
|
||||
daily = pd.read_sql("SELECT ts_code, trade_date, open, high, low, close, volume "
|
||||
"FROM stock_daily ORDER BY ts_code, trade_date", conn)
|
||||
daily['trade_date'] = pd.to_datetime(daily['trade_date'])
|
||||
for c in ('open', 'high', 'low', 'close'):
|
||||
daily[c] = pd.to_numeric(daily[c], errors='coerce')
|
||||
daily = daily.dropna(subset=['open', 'close'])
|
||||
daily = daily[daily['close'] > 0]
|
||||
g = pd.read_sql("SELECT index_code, trade_date, close, pct_change FROM global_index", conn)
|
||||
g['trade_date'] = pd.to_datetime(g['trade_date'])
|
||||
lhb = pd.read_sql("SELECT * FROM lhb_daily", conn)
|
||||
conn.close()
|
||||
return daily, g, lhb
|
||||
|
||||
|
||||
def enrich(daily):
|
||||
"""按指数补充:日收益/隔夜跳空/日内波动/已实现波动率"""
|
||||
out = []
|
||||
for code, df in daily.groupby('ts_code'):
|
||||
df = df.sort_values('trade_date').copy()
|
||||
df['prev_close'] = df['close'].shift(1)
|
||||
df['ret'] = df['close'] / df['prev_close'] - 1
|
||||
df['gap'] = df['open'] / df['prev_close'] - 1
|
||||
df['intra'] = df['close'] / df['open'] - 1
|
||||
df['ret20'] = df['ret'].rolling(20).std()
|
||||
df['ret20_prev'] = df['ret20'].shift(20)
|
||||
df['weekday'] = df['trade_date'].dt.dayofweek
|
||||
out.append(df)
|
||||
return pd.concat(out, ignore_index=True)
|
||||
|
||||
|
||||
def fmt_pct(v, digits=2):
|
||||
return "{:.{}f}%".format(v * 100, digits)
|
||||
|
||||
|
||||
def stat_line(sub, col=None):
|
||||
s = sub.dropna() if hasattr(sub, 'dropna') else sub
|
||||
if len(s) == 0:
|
||||
return "样本不足"
|
||||
up = len(s[s > 0]) / len(s) * 100
|
||||
return "N={}, 均值 {}, 中位数 {}, 胜率 {:.1f}%".format(
|
||||
len(s), fmt_pct(s.mean()), fmt_pct(s.median()), up)
|
||||
|
||||
|
||||
def overnight_matrix(daily, g):
|
||||
lines = ["## 隔夜传导矩阵(美股 -> A股)", "",
|
||||
"> 口径:美股T日涨跌幅分箱 -> A股T+1交易日(跳空=开盘/前收-1;日内=收盘/开盘-1)。"
|
||||
"样本 2005-2026(受 A 股指数历史与纳斯达克 2014 起数据限制)。", ""]
|
||||
for us_code, us_name in [('US.NDX', '纳斯达克100'), ('US.SPX', '标普500'), ('US.DJI', '道琼斯')]:
|
||||
gu = g[g['index_code'] == us_code][['trade_date', 'pct_change']].rename(
|
||||
columns={'pct_change': 'us_ret'})
|
||||
if gu.empty:
|
||||
continue
|
||||
gu['us_ret'] = pd.to_numeric(gu['us_ret'], errors='coerce') / 100.0
|
||||
bins = [-np.inf, -0.015, -0.005, 0.005, 0.015, np.inf]
|
||||
labels = ['跌>1.5%', '跌0.5~1.5%', '正负0.5%内', '涨0.5~1.5%', '涨>1.5%']
|
||||
gu['us_bin'] = pd.cut(gu['us_ret'], bins=bins, labels=labels)
|
||||
for idx_code in ['sh000001', 'sh000300']:
|
||||
a = daily[daily['ts_code'] == idx_code][['trade_date', 'gap', 'intra', 'ret']].copy()
|
||||
a = a.sort_values('trade_date')
|
||||
merged = pd.merge_asof(a, gu.sort_values('trade_date'),
|
||||
left_on='trade_date', right_on='trade_date',
|
||||
direction='backward', allow_exact_matches=False)
|
||||
merged = merged.dropna(subset=['us_bin', 'gap'])
|
||||
name = IDX_NAMES.get(idx_code, idx_code)
|
||||
lines.append("### {}T日 -> {}T+1日".format(us_name, name))
|
||||
lines.append("")
|
||||
for lab in labels:
|
||||
sub = merged[merged['us_bin'] == lab]
|
||||
if len(sub) < 30:
|
||||
lines.append("- 美股{}: 样本不足({})".format(lab, len(sub)))
|
||||
continue
|
||||
lines.append("- 美股{}({}天): A股跳空 {}; 日内 {}; 全天 {}".format(
|
||||
lab, len(sub), stat_line(sub['gap']), stat_line(sub['intra']),
|
||||
stat_line(sub['ret'])))
|
||||
lines.append("")
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def gap_intraday(daily):
|
||||
lines = ["## A股指数跳空回补统计(日外->日内的接力规律)", "",
|
||||
"> 口径:跳空=开盘/前收-1;日内=收盘/开盘-1。日内为负即\"高开低走\"(回落/回补)。", ""]
|
||||
bins = [(-np.inf, -0.01, '大幅低开<-1%'), (-0.01, -0.002, '低开0.2~1%'),
|
||||
(-0.002, 0.002, '平开正负0.2%'), (0.002, 0.01, '高开0.2~1%'),
|
||||
(0.01, np.inf, '大幅高开>1%')]
|
||||
for idx_code in ['sh000001', 'sh000300']:
|
||||
a = daily[daily['ts_code'] == idx_code].dropna(subset=['gap', 'intra'])
|
||||
name = IDX_NAMES.get(idx_code, idx_code)
|
||||
years = "{}-{}".format(a['trade_date'].dt.year.min(), a['trade_date'].dt.year.max())
|
||||
lines.append("### {}({},{}天)".format(name, years, len(a)))
|
||||
for lo, hi, lab in bins:
|
||||
sub = a[(a['gap'] > lo) & (a['gap'] <= hi)]
|
||||
if len(sub) < 30:
|
||||
continue
|
||||
intra_up = (sub['intra'] > 0).mean() * 100
|
||||
lines.append("- {}({}天,占比 {:.1f}%): 日内均值 {},日内收涨概率 {:.1f}%".format(
|
||||
lab, len(sub), len(sub) / len(a) * 100, fmt_pct(sub['intra'].mean()), intra_up))
|
||||
lines.append("")
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def weekday_effect(daily):
|
||||
lines = ["## A股指数周内效应", "",
|
||||
"> 口径:指数日收益按星期聚合(2003/2004-2026)。", ""]
|
||||
wd_names = ['周一', '周二', '周三', '周四', '周五']
|
||||
for idx_code in ['sh000001', 'sh000300']:
|
||||
a = daily[daily['ts_code'] == idx_code].dropna(subset=['ret'])
|
||||
name = IDX_NAMES.get(idx_code, idx_code)
|
||||
lines.append("### {}({}天)".format(name, len(a)))
|
||||
for wd in range(5):
|
||||
sub = a[a['weekday'] == wd]
|
||||
lines.append("- {}: {}".format(wd_names[wd], stat_line(sub['ret'])))
|
||||
lines.append("")
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def volatility_regime(daily):
|
||||
lines = ["## 波动率机制切换(20日波动率翻倍/减半 -> 后5日)", "",
|
||||
"> 口径:20日收益标准差 vs 前20日对比;统计切换后 5 日收益分布。", ""]
|
||||
for idx_code in ['sh000001', 'sh000300']:
|
||||
a = daily[daily['ts_code'] == idx_code].copy()
|
||||
a = a.dropna(subset=['ret20', 'ret20_prev'])
|
||||
name = IDX_NAMES.get(idx_code, idx_code)
|
||||
lines.append("### {}".format(name))
|
||||
for lab, cond in [('骤增(>2倍)', a['ret20'] > a['ret20_prev'] * 2),
|
||||
('骤降(<0.5倍)', a['ret20'] < a['ret20_prev'] * 0.5)]:
|
||||
sub_idx = a[cond].index
|
||||
if len(sub_idx) < 30:
|
||||
lines.append("- {}: 样本不足({})".format(lab, len(sub_idx)))
|
||||
continue
|
||||
fwd = []
|
||||
for idx in sub_idx:
|
||||
pos = a.index.get_loc(idx)
|
||||
window = a.iloc[pos + 1:pos + 6]
|
||||
fwd.append((1 + window['ret']).prod() - 1)
|
||||
fwd = pd.Series(fwd).dropna()
|
||||
lines.append("- {}({}次): 后5日 {}".format(lab, len(sub_idx), stat_line(fwd)))
|
||||
lines.append("")
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def lhb_stats(lhb, daily):
|
||||
lines = ["## 龙虎榜统计(近90个交易日)", "",
|
||||
"> 口径:仅覆盖近90日采集窗口;上榜股只有当日快照,暂无次日数据(不提供次日胜率)。", ""]
|
||||
total_up = (lhb['pct_change'] > 0).sum()
|
||||
lines.append("- 上榜记录 {} 条;上榜当日上涨占比 {:.1f}%".format(len(lhb), total_up / len(lhb) * 100))
|
||||
if 'reason' in lhb.columns:
|
||||
lines.append("- 下跌偏离上榜占比 {:.1f}%(跌幅榜多=弱势环境信号)".format(
|
||||
lhb['reason'].str.contains('跌幅').mean() * 100))
|
||||
lines.append("")
|
||||
lines.append("### 上榜原因分布 TOP10")
|
||||
for reason, cnt in lhb['reason'].value_counts().head(10).items():
|
||||
lines.append("- {}次 | {}".format(cnt, reason))
|
||||
lines.append("")
|
||||
daily_sh = daily[daily['ts_code'] == 'sh000001'][['trade_date', 'ret']]
|
||||
merged = lhb.copy()
|
||||
merged['trade_date'] = pd.to_datetime(merged['trade_date'])
|
||||
m = merged.merge(daily_sh, on='trade_date', how='left')
|
||||
m['mkt_down'] = m['ret'] < 0
|
||||
lines.append("### 大盘状态 × 龙虎榜家数")
|
||||
for down, cnt in m.groupby('mkt_down').size().items():
|
||||
label = '指数下跌日' if down else '指数上涨日'
|
||||
sub = m[m['mkt_down'] == down]
|
||||
lines.append("- {}: 平均上榜 {:.1f} 条({}条/{}天)".format(label, cnt / len(sub), cnt, len(sub)))
|
||||
return chr(10).join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
import os
|
||||
daily, g, lhb = load()
|
||||
daily = enrich(daily)
|
||||
refs = {
|
||||
'overnight_transmission.md': overnight_matrix(daily, g),
|
||||
'gap_intraday.md': gap_intraday(daily),
|
||||
'weekday_volatility.md': weekday_effect(daily) + NL2 + volatility_regime(daily),
|
||||
'lhb_stats.md': lhb_stats(lhb, daily),
|
||||
}
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for name, content in refs.items():
|
||||
out_path = os.path.normpath(os.path.join(OUT, name))
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print("written:", name, len(content), "chars")
|
||||
|
||||
|
||||
NL2 = chr(10) * 2
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# fetcher package
|
||||
@@ -0,0 +1,27 @@
|
||||
"""交易日历采集 - AkShare tool_trade_date_hist_sina"""
|
||||
import warnings, logging, sqlite3
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
import akshare as ak
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB = r'C:\Users\lookt\a_stock_timeline\data\a_stock.db'
|
||||
|
||||
def fetch_calendar():
|
||||
df = ak.tool_trade_date_hist_sina()
|
||||
log.info(f'获取到 {len(df)} 行,列: {df.columns.tolist()}')
|
||||
# 原始只有 trade_date 一列;按 A 股规则补 is_trade_day
|
||||
# 周六=0, 周日=0, 其他=1
|
||||
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
||||
df['is_trade_day'] = df['trade_date'].dt.dayofweek.apply(lambda x: 0 if x >= 5 else 1)
|
||||
df['trade_date'] = df['trade_date'].dt.strftime('%Y-%m-%d')
|
||||
conn = sqlite3.connect(DB)
|
||||
df.to_sql('trade_calendar', conn, if_exists='replace', index=False)
|
||||
conn.close()
|
||||
log.info(f'写入 trade_calendar: {len(df)} 行')
|
||||
return df
|
||||
|
||||
if __name__ == '__main__':
|
||||
fetch_calendar()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""A股新闻采集 - AkShare stock_news_em"""
|
||||
import warnings, logging, sqlite3
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
import akshare as ak
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB = r'C:\Users\lookt\a_stock_timeline\data\a_stock.db'
|
||||
|
||||
def fetch_news():
|
||||
df = ak.stock_news_em(symbol='A股')
|
||||
log.info(f'获取到 {len(df)} 行,列: {df.columns.tolist()}')
|
||||
# 列名标准化
|
||||
cn_map = {
|
||||
'关键词': 'keywords',
|
||||
'股票代码': 'ts_code',
|
||||
'新闻标题': 'title',
|
||||
'新闻内容': 'content',
|
||||
'发布时间': 'pub_date',
|
||||
'文章来源': 'source',
|
||||
'新闻链接': 'url',
|
||||
}
|
||||
df = df.rename(columns=cn_map)
|
||||
# 只保留表里有的列
|
||||
db_cols = ['title', 'content', 'pub_date', 'source', 'url', 'keywords']
|
||||
for c in db_cols:
|
||||
if c not in df.columns:
|
||||
df[c] = ''
|
||||
df = df[['id'] + [c for c in db_cols if c in df.columns]] if 'id' in df.columns else df[db_cols]
|
||||
df['inserted_at'] = datetime.now().isoformat()
|
||||
if 'id' not in df.columns:
|
||||
df.insert(0, 'id', range(1, len(df) + 1))
|
||||
conn = sqlite3.connect(DB)
|
||||
df.to_sql('news_cn', conn, if_exists='replace', index=False)
|
||||
conn.close()
|
||||
log.info(f'写入 news_cn: {len(df)} 行')
|
||||
return df
|
||||
|
||||
if __name__ == '__main__':
|
||||
fetch_news()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""资金流向采集 - 修正版:同花顺个股资金流(逐页校验,跳过异常页),历史保留"""
|
||||
import time
|
||||
import warnings
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from src.storage.db import upsert_rows
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB = r'C:\Users\lookt\a_stock_timeline\data\a_stock.db'
|
||||
|
||||
# 出站请求域名白名单(SSRF 防护:仅 http + 白名单主机,禁重定向跟随)
|
||||
_ALLOWED_HOSTS = {'data.10jqka.com.cn'}
|
||||
_ALLOWED_PREFIX = 'http://data.10jqka.com.cn/funds/ggzjl/'
|
||||
|
||||
|
||||
def _safe_get(url: str, headers: dict, timeout: int = 15) -> requests.Response:
|
||||
"""白名单校验后的 GET:协议/host 白名单 + 禁重定向,防 SSRF"""
|
||||
u = urlparse(url)
|
||||
if u.scheme != 'http' or u.hostname not in _ALLOWED_HOSTS:
|
||||
raise ValueError(f'blocked non-allowlist url: {url}')
|
||||
return requests.get(url, timeout=timeout, allow_redirects=False, headers=headers)
|
||||
|
||||
|
||||
def parse_cn_amount(v):
|
||||
"""'1.5亿' -> 1.5e8, '-300万' -> -3e6,逗号/空白容错;无效返回 None"""
|
||||
if v is None or (isinstance(v, float) and v != v):
|
||||
return None
|
||||
s = str(v).replace(',', '').replace(' ', '').strip()
|
||||
if not s or s in ('--', '-', 'nan'):
|
||||
return None
|
||||
mult = 1.0
|
||||
if s.endswith('亿'):
|
||||
mult, s = 1e8, s[:-1]
|
||||
elif s.endswith('万'):
|
||||
mult, s = 1e4, s[:-1]
|
||||
try:
|
||||
return float(s) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _ths_headers():
|
||||
from akshare.stock_feature.stock_fund_flow import _get_file_content_ths
|
||||
from py_mini_racer import MiniRacer
|
||||
js_code = MiniRacer()
|
||||
js_code.eval(_get_file_content_ths("ths.js"))
|
||||
v_code = js_code.call("v")
|
||||
return {
|
||||
"Accept": "text/html, */*; q=0.01",
|
||||
"hexin-v": v_code,
|
||||
"Host": "data.10jqka.com.cn",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": "http://data.10jqka.com.cn/funds/hyzjl/",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
|
||||
def _page_url(page: int) -> str:
|
||||
"""拼分页 URL;page 强制 int,且必须落在白名单前缀内"""
|
||||
page = int(page)
|
||||
url = _ALLOWED_PREFIX + f"field/zdf/order/desc/page/{page}/ajax/1/free/1/"
|
||||
if not url.startswith(_ALLOWED_PREFIX):
|
||||
raise ValueError('url escapes allowlist prefix')
|
||||
return url
|
||||
|
||||
|
||||
def fetch_individual_flow(max_pages=None, retries_per_page=2):
|
||||
"""
|
||||
同花顺个股资金流排行(修正版):
|
||||
akshare 原实现对部分页的异常 13 列表格直接崩溃,这里逐页校验:
|
||||
只接受恰好 10 列且表头匹配的表格,异常页跳过并记录。
|
||||
"""
|
||||
from bs4 import BeautifulSoup
|
||||
from io import StringIO
|
||||
|
||||
headers = _ths_headers() # 每次请求刷新 hexin-v
|
||||
expect = ['序号', '股票代码', '股票简称', '最新价', '涨跌幅', '换手率',
|
||||
'流入资金(元)', '流出资金(元)', '净额(元)', '成交额(元)']
|
||||
|
||||
r = _safe_get(_page_url(1), headers)
|
||||
soup = BeautifulSoup(r.text, features="lxml")
|
||||
page_info = soup.find(name="span", attrs={"class": "page_info"})
|
||||
page_num = int(page_info.text.split("/")[1]) if page_info else 1
|
||||
if max_pages:
|
||||
page_num = min(page_num, max_pages)
|
||||
|
||||
frames = []
|
||||
skipped = []
|
||||
for page in range(1, page_num + 1):
|
||||
for attempt in range(retries_per_page):
|
||||
try:
|
||||
r = _safe_get(_page_url(page), headers)
|
||||
dfs = pd.read_html(StringIO(r.text))
|
||||
df = next((d for d in dfs if len(d.columns) == 10
|
||||
and list(d.columns)[:2] == ['序号', '股票代码']), None)
|
||||
if df is None:
|
||||
skipped.append(page)
|
||||
break
|
||||
df.columns = expect
|
||||
frames.append(df)
|
||||
break
|
||||
except Exception:
|
||||
if attempt == retries_per_page - 1:
|
||||
skipped.append(page)
|
||||
else:
|
||||
time.sleep(1)
|
||||
time.sleep(0.3) # 限速,避免同花顺封禁
|
||||
if skipped:
|
||||
log.warning(f'跳过异常页: {skipped}')
|
||||
if not frames:
|
||||
return pd.DataFrame()
|
||||
big = pd.concat(frames, ignore_index=True)
|
||||
big.columns = [c.split('(')[0] for c in big.columns] # 去掉 (元) 后缀
|
||||
return big
|
||||
|
||||
|
||||
def fetch_money_flow():
|
||||
"""采集全市场个股当日主力净流入(净额),并入库(历史保留)"""
|
||||
today = time.strftime('%Y-%m-%d')
|
||||
raw = fetch_individual_flow()
|
||||
if raw.empty:
|
||||
log.warning('个股资金流采集为空')
|
||||
return None
|
||||
|
||||
df = pd.DataFrame({
|
||||
'ts_code': raw['股票代码'].astype(str).str.zfill(6),
|
||||
'trade_date': today,
|
||||
'main_net_in': raw['净额'].map(parse_cn_amount),
|
||||
'in_flow': raw['流入资金'].map(parse_cn_amount),
|
||||
'out_flow': raw['流出资金'].map(parse_cn_amount),
|
||||
'amount': raw['成交额'].map(parse_cn_amount),
|
||||
'pct_change': raw['涨跌幅'].str.rstrip('%').map(
|
||||
lambda x: float(x) if x and x not in ('--', '') else None),
|
||||
})
|
||||
df = df[df['main_net_in'].notna()]
|
||||
n = upsert_rows(df, 'money_flow', conflict_cols=['ts_code', 'trade_date'])
|
||||
log.info(f'写入 money_flow: {n} 行(历史保留)')
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
fetch_money_flow()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""外盘指数采集 - AkShare index_us_stock_sina"""
|
||||
import warnings, logging, sqlite3
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
import akshare as ak
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB = r'C:\Users\lookt\a_stock_timeline\data\a_stock.db'
|
||||
|
||||
# 要采集的外盘指数
|
||||
INDICES = [
|
||||
('US.DJI', '.DJI', '道琼斯工业'),
|
||||
('US.NDX', '.NDX', '纳斯达克100'),
|
||||
('US.SPX', '.INX', '标普500'),
|
||||
]
|
||||
|
||||
def fetch_global_index():
|
||||
conn = sqlite3.connect(DB)
|
||||
results = []
|
||||
for code, symbol, name in INDICES:
|
||||
try:
|
||||
df = ak.index_us_stock_sina(symbol=symbol)
|
||||
log.info(f'{name}({symbol}): {len(df)} 行,列: {df.columns.tolist()}')
|
||||
# 标准化
|
||||
cn_map = {
|
||||
'时间': 'trade_date',
|
||||
'日期': 'trade_date',
|
||||
'date': 'trade_date',
|
||||
'收盘': 'close',
|
||||
'close': 'close',
|
||||
'涨跌幅': 'pct_change',
|
||||
'pct_change': 'pct_change',
|
||||
}
|
||||
df = df.rename(columns=cn_map)
|
||||
if 'trade_date' not in df.columns:
|
||||
df['trade_date'] = df['date'] if 'date' in df.columns else None
|
||||
df['index_code'] = code
|
||||
df['trade_date'] = pd.to_datetime(df['trade_date']).dt.strftime('%Y-%m-%d')
|
||||
# 计算涨跌幅(基于前一日收盘价)
|
||||
df = df.sort_values('trade_date')
|
||||
df['pct_change'] = df['close'].pct_change() * 100
|
||||
out = df[['index_code', 'trade_date', 'close', 'pct_change']].dropna()
|
||||
out.to_sql('global_index', conn, if_exists='append', index=False)
|
||||
results.append((code, len(out)))
|
||||
log.info(f' -> 写入 {code}: {len(out)} 行')
|
||||
except Exception as e:
|
||||
log.warning(f'{name}({symbol}) 失败: {e}')
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
if __name__ == '__main__':
|
||||
fetch_global_index()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
龙虎榜采集器
|
||||
主方案:AkShare stock_lhb_detail_daily_sina(已实测可用,含上榜原因)
|
||||
备选:东财 datacenter 接口(报表名不确定,暂时注释备用)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from src.storage.db import upsert_df, upsert_log, get_last_fetch
|
||||
|
||||
|
||||
def fetch_lhb_date(trade_date: str) -> pd.DataFrame:
|
||||
"""
|
||||
获取单日龙虎榜
|
||||
trade_date: "YYYY-MM-DD" 或 "YYYYMMDD"
|
||||
"""
|
||||
# 统一为 YYYYMMDD 格式
|
||||
ds = trade_date.replace("-", "")
|
||||
try:
|
||||
df = ak.stock_lhb_detail_daily_sina(date=ds)
|
||||
except Exception as e:
|
||||
print(f" [WARN] LHB Sina failed for {trade_date}: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Sina 返回列(实测):序号, 股票代码, 股票名称, 收盘价, 涨跌幅, 成交额, 成交额, 上榜原因
|
||||
# 编码问题导致中文列名显示乱码,用位置索引映射
|
||||
# 0=序号, 1=代码, 2=名称, 3=收盘, 4=涨跌幅, 5=成交额, 6=??? 7=上榜原因
|
||||
df.columns = ["no", "ts_code", "name", "close", "pct_change", "amount", "amount2", "reason"]
|
||||
|
||||
df["trade_date"] = trade_date.replace("-", "")
|
||||
# 格式化为 YYYY-MM-DD
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d")
|
||||
|
||||
# 保留有用字段
|
||||
keep = ["trade_date", "ts_code", "name", "close", "pct_change", "amount", "reason"]
|
||||
df = df[[c for c in keep if c in df.columns]]
|
||||
return df
|
||||
|
||||
|
||||
def fetch_lhb_range(start_date: str, end_date: str = None) -> pd.DataFrame:
|
||||
"""抓取日期区间的龙虎榜(逐日)"""
|
||||
end_date = end_date or datetime.today().strftime("%Y-%m-%d")
|
||||
all_days = []
|
||||
cur = datetime.strptime(start_date.replace("-", ""), "%Y%m%d")
|
||||
end = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
|
||||
while cur <= end:
|
||||
ds = cur.strftime("%Y-%m-%d")
|
||||
df = fetch_lhb_date(ds)
|
||||
if df is not None and not df.empty:
|
||||
all_days.append(df)
|
||||
cur += timedelta(days=1)
|
||||
if all_days:
|
||||
return pd.concat(all_days, ignore_index=True)
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def incremental_lhb(source: str = "lhb_daily") -> pd.DataFrame:
|
||||
"""增量采集:只抓上次之后的新交易日"""
|
||||
last = get_last_fetch(source)
|
||||
start = last.get("last_date") or (datetime.today() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
end = datetime.today().strftime("%Y-%m-%d")
|
||||
df = fetch_lhb_range(start, end)
|
||||
if df is not None and not df.empty:
|
||||
upsert_df(df, "lhb_daily")
|
||||
upsert_log(source, param="", last_date=end)
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import fire
|
||||
fire.Fire({
|
||||
"date": lambda d: upsert_df(fetch_lhb_date(d), "lhb_daily"),
|
||||
"range": lambda s, e=None: upsert_df(fetch_lhb_range(s, e), "lhb_daily"),
|
||||
"incr": lambda: incremental_lhb(),
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
每日大事提醒 / 财经日历采集器
|
||||
主方案:AkShare news_economic_baidu(财经日历事件,日期/时间/重要性完整)
|
||||
备选:东财 datacenter 大事提醒接口
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
import akshare as ak
|
||||
from datetime import datetime, timedelta
|
||||
from src.storage.db import upsert_df, upsert_rows, upsert_log, get_last_fetch
|
||||
|
||||
EM_BASE = "https://datacenter-web.eastmoney.com/api/data/v1/get"
|
||||
|
||||
|
||||
def em_headers() -> dict:
|
||||
return {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://data.eastmoney.com/",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
# ── 主方案:AkShare 财经日历 ─────────────────────────────────────────────────
|
||||
|
||||
def fetch_macro_calendar() -> pd.DataFrame:
|
||||
"""
|
||||
AkShare news_economic_baidu 返回财经日历事件
|
||||
列: 标题, 时间, 重要性, 前值, 预测值, 公布值, 影响
|
||||
"""
|
||||
try:
|
||||
df = ak.news_economic_baidu()
|
||||
except Exception as e:
|
||||
print(f" [WARN] news_economic_baidu failed: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 实测列(akshare 1.18.92): 日期, 时间, 地区, 事件, 公布, 预期, 前值, 重要性
|
||||
# 按列【名称】映射,避免位置错位(严禁再按位置覆盖 df.columns)
|
||||
col_map = {'日期': 'event_date', '时间': 'event_time', '地区': 'source',
|
||||
'事件': 'title', '公布': 'actual', '预期': 'forecast',
|
||||
'前值': 'prev', '重要性': 'event_type'}
|
||||
df = df.rename(columns=col_map)
|
||||
|
||||
# 防御:akshare 偶发返回缺列,补默认值
|
||||
for c, default in [('event_time', ''), ('actual', None), ('forecast', None),
|
||||
('prev', None), ('event_type', 0), ('source', '')]:
|
||||
if c not in df.columns:
|
||||
df[c] = default
|
||||
df["title"] = df["title"].fillna("").astype(str)
|
||||
df["event_date"] = pd.to_datetime(df["event_date"], errors="coerce").dt.strftime("%Y-%m-%d")
|
||||
df["event_time"] = df["event_time"].fillna("").astype(str)
|
||||
df["event_type"] = pd.to_numeric(df["event_type"], errors="coerce").fillna(0).astype(int)
|
||||
df["source"] = df["source"].fillna("").astype(str)
|
||||
|
||||
keep = ["event_date", "event_time", "title", "event_type", "source",
|
||||
"actual", "forecast", "prev"]
|
||||
df = df[[c for c in keep if c in df.columns]]
|
||||
return df
|
||||
|
||||
|
||||
# ── 备选:东财大事提醒接口 ───────────────────────────────────────────────────
|
||||
|
||||
def fetch_em_events(
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
东财大事提醒接口(需确认报表名)
|
||||
已知可用报表: RPT_MAJOR_NOTICE(公告)、RPT_LHB_ALL_STOCKS(龙虎榜)
|
||||
大事提醒报表名待抓包确认,暂时返回空
|
||||
"""
|
||||
if start_date is None:
|
||||
start_date = (datetime.today() - timedelta(days=3)).strftime("%Y-%m-%d")
|
||||
if end_date is None:
|
||||
end_date = (datetime.today() + timedelta(days=60)).strftime("%Y-%m-%d")
|
||||
|
||||
# 尝试 RPT_MAJOR_NOTICES(公告大事)
|
||||
report_names = ["RPT_MAJOR_NOTICES", "RPT_IMPORTANT_NEWS"]
|
||||
for report in report_names:
|
||||
params = {
|
||||
"reportName": report,
|
||||
"columns": "ALL",
|
||||
"filter": f'(EVENT_DATE>=\'{start_date}\') and (EVENT_DATE<=\'{end_date}\')',
|
||||
"pageNumber": page,
|
||||
"pageSize": page_size,
|
||||
"sortTypes": "1",
|
||||
"sortColumns": "EVENT_DATE",
|
||||
"source": "WEB",
|
||||
"client": "WEB",
|
||||
}
|
||||
try:
|
||||
r = requests.get(EM_BASE, params=params, headers=em_headers(), timeout=15)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
result = resp.get("result", {}) or {}
|
||||
data = result.get("data", []) or []
|
||||
if data:
|
||||
df = pd.DataFrame(data)
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
return df
|
||||
except Exception:
|
||||
continue
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def incremental_events(source: str = "macro_event") -> pd.DataFrame:
|
||||
"""增量采集宏观事件(按 event_date+event_time+title 去重,历史保留)"""
|
||||
df = fetch_macro_calendar()
|
||||
if df is not None and not df.empty:
|
||||
need = [c for c in ['event_date', 'event_time', 'title', 'event_type', 'source',
|
||||
'actual', 'forecast', 'prev', 'inserted_at'] if c in df.columns]
|
||||
upsert_rows(df[need], source,
|
||||
conflict_cols=['event_date', 'event_time', 'title'])
|
||||
upsert_log(source, "", last_date=datetime.today().strftime("%Y-%m-%d"))
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import fire
|
||||
fire.Fire({
|
||||
"ak": lambda: upsert_df(fetch_macro_calendar(), "macro_event"),
|
||||
"em": lambda s=None, e=None: upsert_df(fetch_em_events(s, e), "macro_event"),
|
||||
"incr": lambda: incremental_events(),
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
7x24 快讯流采集器
|
||||
主方案:东财 np-anotice-stock 快讯接口(秒级时间戳)
|
||||
备选方案:AkShare news_economic_baidu(财经日历事件)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
import akshare as ak
|
||||
from datetime import datetime
|
||||
from src.storage.db import upsert_df, upsert_log, get_last_fetch
|
||||
|
||||
|
||||
# ── 主方案:东财快讯接口 ────────────────────────────────────────────────────
|
||||
|
||||
EM_FLASH_API = "https://np-anotice-stock.eastmoney.com/api/security/ann"
|
||||
|
||||
def fetch_em_flash(category: str = "全部", page: int = 1, page_size: int = 50) -> dict | None:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"Referer": "https://www.eastmoney.com/",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
params = {
|
||||
"sr": -1,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"category": category,
|
||||
"type": "",
|
||||
}
|
||||
try:
|
||||
r = requests.get(EM_FLASH_API, params=params, headers=headers, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
print(f" [WARN] EastMoney flash request failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_em_flash(resp: dict) -> list[dict]:
|
||||
"""解析东财快讯响应"""
|
||||
try:
|
||||
items = resp.get("data", []) or resp.get("list", []) or []
|
||||
return items
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def fetch_flash_pages(category: str = "全部", pages: int = 3, page_size: int = 30) -> pd.DataFrame:
|
||||
"""抓取多页快讯"""
|
||||
all_items = []
|
||||
for page in range(1, pages + 1):
|
||||
resp = fetch_em_flash(category, page=page, page_size=page_size)
|
||||
if resp is None:
|
||||
break
|
||||
items = parse_em_flash(resp)
|
||||
if not items:
|
||||
break
|
||||
all_items.extend(items)
|
||||
|
||||
if not all_items:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(all_items)
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
|
||||
# 字段映射(东财字段 → 标准名)
|
||||
col_map = {
|
||||
"title": "content",
|
||||
"showtime": "event_time",
|
||||
"time": "event_time",
|
||||
"notice_date":"event_time",
|
||||
"source": "source",
|
||||
"media": "source",
|
||||
"site": "source",
|
||||
"url": "url",
|
||||
}
|
||||
df = df.rename(columns={k: v for k, v in col_map.items() if k in df.columns})
|
||||
|
||||
# 时间标准化
|
||||
if "event_time" in df.columns:
|
||||
df["event_time"] = pd.to_datetime(df["event_time"], errors="coerce").dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
keep = ["content", "event_time", "source"]
|
||||
df = df[[c for c in keep if c in df.columns]]
|
||||
return df
|
||||
|
||||
|
||||
# ── 备选方案:AkShare 新闻 ───────────────────────────────────────────────────
|
||||
|
||||
def fetch_akshare_news() -> pd.DataFrame:
|
||||
"""AkShare 东财财经新闻(带发布时间戳)
|
||||
stock_news_em 返回列: [关键词, 股票代码, 新闻标题, 发布时间, 新闻来源, 新闻内容]
|
||||
"""
|
||||
try:
|
||||
df = ak.stock_news_em(symbol="A股")
|
||||
except Exception as e:
|
||||
print(f" [WARN] AkShare news failed: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
if df is None or df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# AkShare 返回中文列名,用位置映射更稳定
|
||||
# 列0=关键词, 1=股票代码, 2=新闻标题(content), 3=发布时间, 4=新闻来源, 5=新闻内容
|
||||
df.columns = ["keyword", "ts_code", "content", "event_time", "source", "body"]
|
||||
if "event_time" in df.columns:
|
||||
df["event_time"] = pd.to_datetime(df["event_time"], errors="coerce").dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
keep = ["content", "event_time", "source"]
|
||||
return df[keep]
|
||||
|
||||
|
||||
# ── 入口函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def incremental_flash(source: str = "news_flash") -> pd.DataFrame:
|
||||
"""
|
||||
增量采集:优先东财快讯,降级到 AkShare 新闻
|
||||
"""
|
||||
# 尝试东财快讯(category="全部" 获取最广)
|
||||
df = fetch_flash_pages(category="全部", pages=3)
|
||||
if df is None or df.empty:
|
||||
df = fetch_akshare_news()
|
||||
|
||||
if df is not None and not df.empty:
|
||||
upsert_df(df, source, csv备份=False)
|
||||
upsert_log(source, "", last_ts=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import fire
|
||||
fire.Fire({
|
||||
"em": lambda c="全部", p=3: upsert_df(fetch_flash_pages(c, pages=p), "news_flash"),
|
||||
"ak": lambda: upsert_df(fetch_akshare_news(), "news_flash"),
|
||||
"incr": lambda: incremental_flash(),
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
日K线采集器 — AkShare(免费,无需 Token)
|
||||
支持前复权日线、分钟线(东财)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
import akshare as ak
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from src.storage.db import upsert_df, upsert_rows, upsert_log, get_last_fetch
|
||||
|
||||
|
||||
def fetch_daily(
|
||||
ts_code: str,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
adjust: str = "qfq"
|
||||
) -> pd.DataFrame | None:
|
||||
"""
|
||||
获取单只A股日K线(前复权)
|
||||
ts_code: 6位代码,如 "000001"
|
||||
start_date / end_date: "YYYYMMDD"
|
||||
adjust: "qfq" 前复权 / "hfq" 后复权 / "" 不复权
|
||||
"""
|
||||
try:
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=ts_code,
|
||||
period="daily",
|
||||
start_date=start_date or (datetime.today() - timedelta(days=365)).strftime("%Y%m%d"),
|
||||
end_date=end_date or datetime.today().strftime("%Y%m%d"),
|
||||
adjust=adjust
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [WARN] stock_zh_a_hist failed for {ts_code}: {e}")
|
||||
return None
|
||||
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
# AkShare 列名统一转小写
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
|
||||
# 统一列名映射(兼容多种 AkShare 版本列名)
|
||||
col_map = {}
|
||||
for old in df.columns:
|
||||
ol = old.lower()
|
||||
if ol in ("日期", "date"):
|
||||
col_map[old] = "trade_date"
|
||||
elif ol in ("股票代码", "代码", "symbol", "code"):
|
||||
col_map[old] = "ts_code"
|
||||
elif ol in ("开盘", "open"):
|
||||
col_map[old] = "open"
|
||||
elif ol in ("最高", "high"):
|
||||
col_map[old] = "high"
|
||||
elif ol in ("最低", "low"):
|
||||
col_map[old] = "low"
|
||||
elif ol in ("收盘", "close"):
|
||||
col_map[old] = "close"
|
||||
elif ol in ("成交量", "volume", "vol"):
|
||||
col_map[old] = "volume"
|
||||
elif ol in ("成交额", "amount", "amt"):
|
||||
col_map[old] = "amount"
|
||||
df = df.rename(columns=col_map)
|
||||
|
||||
# 确保 ts_code 列存在
|
||||
if "ts_code" not in df.columns:
|
||||
df["ts_code"] = ts_code
|
||||
|
||||
# 只保留核心列
|
||||
keep = ["ts_code", "trade_date", "open", "high", "low", "close", "volume", "amount"]
|
||||
df = df[[c for c in keep if c in df.columns]]
|
||||
|
||||
# 日期格式统一为 YYYY-MM-DD
|
||||
if "trade_date" in df.columns:
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def fetch_index_daily(
|
||||
symbol: str = "sh000001", # 注意:AkShare 需 sh/zs 前缀,如 sh000001
|
||||
start_date: str = None,
|
||||
end_date: str = None
|
||||
) -> pd.DataFrame | None:
|
||||
"""获取指数日K线(用于大盘背景板)"""
|
||||
try:
|
||||
df = ak.stock_zh_index_daily(symbol=symbol)
|
||||
except Exception as e:
|
||||
print(f" [WARN] stock_zh_index_daily failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
|
||||
# AkShare 1.18+ 返回列名: date, open, high, low, close, volume(无 amount)
|
||||
col_map = {"date": "trade_date", "open": "open", "high": "high",
|
||||
"low": "low", "close": "close", "volume": "volume"}
|
||||
df = df.rename(columns={k: v for k, v in col_map.items() if k in df.columns})
|
||||
# amount 列不存在于指数,设为 NaN 保持 schema 一致
|
||||
if "amount" not in df.columns:
|
||||
df["amount"] = None
|
||||
|
||||
if "trade_date" in df.columns:
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d")
|
||||
|
||||
# 按日期过滤
|
||||
if start_date:
|
||||
df = df[df["trade_date"] >= start_date]
|
||||
if end_date:
|
||||
df = df[df["trade_date"] <= end_date]
|
||||
|
||||
if "ts_code" not in df.columns:
|
||||
df["ts_code"] = symbol
|
||||
return df
|
||||
|
||||
|
||||
def batch_fetch_indices(
|
||||
symbols: list[str] = None,
|
||||
days: int = 90
|
||||
) -> pd.DataFrame:
|
||||
"""批量获取主要指数(沪深300/上证/创业板等)"""
|
||||
# AkShare 前缀:sh=上证, sz=深圳
|
||||
symbols = symbols or [
|
||||
"sh000001", # 上证指数
|
||||
"sz399001", # 深证成指
|
||||
"sz399006", # 创业板指
|
||||
"sh000300", # 沪深300
|
||||
"sh000016", # 上证50
|
||||
]
|
||||
end = datetime.today().strftime("%Y-%m-%d")
|
||||
start = (datetime.today() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
results = []
|
||||
for sym in symbols:
|
||||
df = fetch_index_daily(sym)
|
||||
if df is not None and not df.empty:
|
||||
# 日期过滤在内存做
|
||||
if "trade_date" in df.columns:
|
||||
df = df[(df["trade_date"] >= start) & (df["trade_date"] <= end)]
|
||||
if not df.empty:
|
||||
results.append(df)
|
||||
|
||||
if results:
|
||||
return pd.concat(results, ignore_index=True)
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def run(ts_codes: list[str] = None, days: int = 90):
|
||||
"""
|
||||
采集入口
|
||||
ts_codes: 指定代码列表,None 时采集主要指数
|
||||
"""
|
||||
end = datetime.today().strftime("%Y%m%d")
|
||||
start = (datetime.today() - timedelta(days=days)).strftime("%Y%m%d")
|
||||
|
||||
if ts_codes:
|
||||
for code in ts_codes:
|
||||
df = fetch_daily(code, start, end)
|
||||
if df is not None and not df.empty:
|
||||
upsert_rows(df, "stock_daily", conflict_cols=["ts_code", "trade_date"])
|
||||
upsert_log("akshare_daily", code, last_date=end)
|
||||
else:
|
||||
# 默认采集主要指数
|
||||
df = batch_fetch_indices(days=days)
|
||||
if df is not None and not df.empty:
|
||||
upsert_rows(df, "stock_daily", conflict_cols=["ts_code", "trade_date"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import fire
|
||||
fire.Fire({
|
||||
"daily": lambda ts_code: upsert_df(fetch_daily(ts_code), "stock_daily"),
|
||||
"indices": lambda: run(),
|
||||
"batch": lambda ts_codes: run(ts_codes=ts_codes),
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
P0 全量采集脚本 — 依次执行所有 P0 数据源
|
||||
用法: python -m src.pipeline.collect_all
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def run():
|
||||
print(f"\n{'='*50}")
|
||||
print(f" A股时间线数据采集 — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
# 1. 日K线(主要指数)
|
||||
print("[1/4] 日K线(主要指数 90天)...")
|
||||
from src.fetcher import stock_api
|
||||
stock_api.run()
|
||||
|
||||
# 2. 龙虎榜(近7日)
|
||||
print("\n[2/4] 龙虎榜(近7日)...")
|
||||
from src.fetcher import lhb_fetcher
|
||||
start = (datetime.today() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
end = datetime.today().strftime("%Y-%m-%d")
|
||||
df = lhb_fetcher.fetch_lhb_range(start, end)
|
||||
from src.storage.db import upsert_df, upsert_log
|
||||
if df is not None and not df.empty:
|
||||
upsert_df(df, "lhb_daily")
|
||||
upsert_log("lhb_daily", "", last_date=end)
|
||||
|
||||
# 3. 7x24 快讯(最新3页)
|
||||
print("\n[3/4] 7x24 快讯流...")
|
||||
from src.fetcher import news_flash_fetcher
|
||||
news_flash_fetcher.incremental_flash()
|
||||
|
||||
# 4. 大事提醒
|
||||
print("\n[4/4] 每日大事提醒...")
|
||||
from src.fetcher import macro_event_fetcher
|
||||
macro_event_fetcher.incremental_events()
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" 采集完成")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
实时市场时间线分析器(常驻运行,不自动退出)
|
||||
==========================================
|
||||
功能(每分钟一轮,按市场状态自动分派):
|
||||
1. 盘前(09:15 前):美股隔夜收盘 → 依据 a-stock-timeline-patterns 实证先验
|
||||
输出今日 A 股跳空/日内路径的历史概率预估
|
||||
2. 盘中(09:15-15:05):上证指数实时点位相对昨收位置 + 快讯关键词告警
|
||||
3. 盘后(15:10):当日龙虎榜入库 + 日报(指数涨跌/跳空/日内分解)
|
||||
输出:控制台 + 追加写入 ~/.a_stock_timeline/realtime_feed.jsonl 与 signal 日志
|
||||
停止:Ctrl+C(优雅退出)
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
import akshare as ak
|
||||
import requests
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DB = os.path.join(ROOT, 'data', 'a_stock.db')
|
||||
FEED = os.path.join(os.path.expanduser('~'), '.a_stock_timeline', 'realtime_feed.jsonl')
|
||||
SKILL_REF = r'E:\Data\skills\a-stock-timeline-patterns\references\overnight_transmission.md'
|
||||
|
||||
KEYWORDS = ['降息', '降准', '加息', '关税', '制裁', '收购', '重组', '国债', '证监会', 'PMI', 'CPI']
|
||||
|
||||
HIS = None # 历史先验缓存 {('NDX'|'SPX'|'DJI', bin): {...}}
|
||||
|
||||
|
||||
def load_priors():
|
||||
"""解析实证先验表(overnight_transmission.md),供盘前预估"""
|
||||
global HIS
|
||||
import re
|
||||
HIS = {}
|
||||
path = SKILL_REF
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
cur_bin, cur_us, cur_idx = None, None, None
|
||||
with io.open(path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
m = re.match(r'### (\S+)T日 -> (\S+)T\+1日', line)
|
||||
if m:
|
||||
cur_us, cur_idx = m.group(1), m.group(2)
|
||||
continue
|
||||
m = re.match(r'- 美股(\S+?)((\d+)天): A股跳空 N=\d+, 均值 (-?[\d.]+)%, 中位数 (-?[\d.]+)%, 胜率 ([\d.]+)%; '
|
||||
r'日内 N=\d+, 均值 (-?[\d.]+)%, 中位数 (-?[\d.]+)%, 胜率 ([\d.]+)%', line)
|
||||
if m and cur_us and cur_idx:
|
||||
cur_bin = m.group(1)
|
||||
HIS[(cur_us, cur_idx, cur_bin)] = {
|
||||
'n': int(m.group(2)),
|
||||
'gap_mean': float(m.group(3)), 'gap_median': float(m.group(4)),
|
||||
'gap_win': float(m.group(5)),
|
||||
'intra_mean': float(m.group(6)), 'intra_median': float(m.group(7)),
|
||||
'intra_win': float(m.group(8)),
|
||||
}
|
||||
print('[init] 实证先验加载: {} 条'.format(len(HIS)), flush=True)
|
||||
|
||||
|
||||
def db_conn():
|
||||
conn = sqlite3.connect(DB, check_same_thread=False)
|
||||
return conn
|
||||
|
||||
|
||||
def us_overnight(conn):
|
||||
"""最近一个已收盘的美股交易日涨跌(三大指数)"""
|
||||
out = {}
|
||||
for code in ('US.NDX', 'US.SPX', 'US.DJI'):
|
||||
row = conn.execute(
|
||||
"SELECT trade_date, pct_change FROM global_index WHERE index_code=? "
|
||||
"ORDER BY trade_date DESC LIMIT 1", (code,)).fetchone()
|
||||
if row:
|
||||
out[code] = {'date': row[0], 'pct': row[1] if row[1] is not None else 0.0}
|
||||
return out
|
||||
|
||||
|
||||
def forecast_from_priors(us):
|
||||
"""按先验表预估:取纳斯达克分箱对应的上证跳空/日内历史分布"""
|
||||
if not HIS or 'US.NDX' not in us:
|
||||
return None
|
||||
pct = us['US.NDX']['pct'] / 100.0
|
||||
if pct <= -0.015:
|
||||
b = '跌>1.5%'
|
||||
elif pct <= -0.005:
|
||||
b = '跌0.5~1.5%'
|
||||
elif pct < 0.005:
|
||||
b = '正负0.5%内'
|
||||
elif pct < 0.015:
|
||||
b = '涨0.5~1.5%'
|
||||
else:
|
||||
b = '涨>1.5%'
|
||||
k = ('US.NDX', 'sh000001', b)
|
||||
if k not in HIS:
|
||||
return None
|
||||
p = HIS[k]
|
||||
return ('隔夜预估[美股纳指{:+.2f}% -> 分箱"{}"]: 历史上上证次日跳空均值 {:+.2f}%(低开概率 {:.0f}%),'
|
||||
'日内均值 {:+.2f}%(日内收涨概率 {:.0f}%),全天均值 {:+.2f}%。'
|
||||
'(样本{}天,美股99-24先验,仅参考)').format(
|
||||
us['US.NDX']['pct'], b, p['gap_mean'], 100 - p['gap_win'],
|
||||
p['intra_mean'], p['intra_win'], p['gap_mean'] + p['intra_mean'], p['n'])
|
||||
|
||||
|
||||
def index_spot_sh():
|
||||
"""上证指数实时点位(新浪,免token)"""
|
||||
try:
|
||||
df = ak.stock_zh_index_spot_em(symbol='上证系列指数')
|
||||
row = df[df['名称'] == '上证指数']
|
||||
if not row.empty:
|
||||
r = row.iloc[0]
|
||||
return {'price': float(r['最新价']), 'pct': float(r['涨跌幅'])}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
df = ak.stock_zh_index_spot_sina()
|
||||
row = df[df['代码'] == 'sh000001']
|
||||
if not row.empty:
|
||||
r = row.iloc[0]
|
||||
return {'price': float(r['最新价']), 'pct': float(r['涨跌幅'])}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def today_lhb_count(conn):
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
n = conn.execute("SELECT COUNT(*) FROM lhb_daily WHERE trade_date=?", (today,)).fetchone()[0]
|
||||
return n
|
||||
|
||||
|
||||
def news_alerts(conn):
|
||||
"""扫描快讯/新闻关键词命中(近2小时新增)"""
|
||||
hits = []
|
||||
cutoff = (datetime.now() - timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
for table, tcol, ccol in (('news_flash', 'event_time', 'content'),
|
||||
('news_cn', 'pub_date', 'title')):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT {tcol}, {ccol} FROM {table} WHERE {tcol} >= ? ORDER BY {tcol} DESC LIMIT 50",
|
||||
(cutoff,)).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue
|
||||
for ts, text in rows:
|
||||
for kw in KEYWORDS:
|
||||
if kw in (text or ''):
|
||||
hits.append({'time': ts, 'kw': kw, 'text': (text or '')[:80]})
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def emit(kind, payload):
|
||||
rec = {'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'kind': kind, 'data': payload}
|
||||
line = json.dumps(rec, ensure_ascii=False)
|
||||
os.makedirs(os.path.dirname(FEED), exist_ok=True)
|
||||
with io.open(FEED, 'a', encoding='utf-8') as f:
|
||||
f.write(line + '\n')
|
||||
print('[{}] {} {}'.format(rec['ts'], kind, json.dumps(payload, ensure_ascii=False)), flush=True)
|
||||
|
||||
|
||||
def save_lhb_today(conn):
|
||||
"""收盘后采集当日龙虎榜"""
|
||||
try:
|
||||
from src.fetcher.lhb_fetcher import fetch_lhb # 项目内已有实现
|
||||
df = fetch_lhb()
|
||||
if df is not None and not df.empty:
|
||||
from src.storage.db import upsert_rows
|
||||
upsert_rows(df, 'lhb_daily', conflict_cols=['trade_date', 'ts_code', 'reason'])
|
||||
return len(df)
|
||||
except Exception as e:
|
||||
print('[lhb] 采集失败:', e, flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
|
||||
def fetch_em_flash(conn):
|
||||
"""东财 7x24 快讯轮询:增量入库 news_flash(按 content+event_time 去重)"""
|
||||
try:
|
||||
r = requests.get(
|
||||
'https://np-weblist.eastmoney.com/comm/web/getFastNewsList',
|
||||
params={'client': 'web', 'biz': 'web_724', 'fastColumn': '102',
|
||||
'sortEnd': '', 'pageSize': '20', 'req_trace': '1'},
|
||||
headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
||||
data = r.json().get('data', {}) or {}
|
||||
rows = []
|
||||
for n in data.get('fastNewsList', []) or []:
|
||||
ts = n.get('showTime', '')
|
||||
summary = (n.get('summary') or n.get('title') or '').strip()
|
||||
if ts and summary:
|
||||
dup = conn.execute(
|
||||
"SELECT 1 FROM news_flash WHERE event_time=? AND content=? LIMIT 1",
|
||||
(ts, summary)).fetchone()
|
||||
if not dup:
|
||||
conn.execute(
|
||||
"INSERT INTO news_flash(content, event_time, source, inserted_at) "
|
||||
"VALUES (?,?,?,datetime('now','localtime'))",
|
||||
(summary, ts, '东财7x24'))
|
||||
rows.append((ts, summary[:60]))
|
||||
conn.commit()
|
||||
return rows
|
||||
except Exception as e:
|
||||
print('[flash] 拉取失败:', e, flush=True)
|
||||
return []
|
||||
|
||||
def one_cycle(conn, state):
|
||||
now = datetime.now()
|
||||
t = now.time()
|
||||
hm = t.hour * 100 + t.minute
|
||||
|
||||
# 1) 盘前(07:00-09:25):隔夜预估
|
||||
if 700 <= hm < 925 and not state.get('premarket_done'):
|
||||
us = us_overnight(conn)
|
||||
fc = forecast_from_priors(us)
|
||||
emit('盘前隔夜预估', {'us': us, 'forecast': fc})
|
||||
state['premarket_done'] = True
|
||||
|
||||
# 2) 盘中(09:25-15:05):实时点位 + 快讯告警
|
||||
if 925 <= hm < 1505:
|
||||
for ts, txt in fetch_em_flash(conn):
|
||||
emit('新快讯', {'time': ts, 'text': txt})
|
||||
spot = index_spot_sh()
|
||||
if spot and spot.get('price', 0) > 0:
|
||||
if 'session_open_price' not in state or hm < 940 and state.get('last_price') is None:
|
||||
pass
|
||||
emit('盘中点位', {'上证': spot['price'], '涨跌幅%': spot['pct']})
|
||||
hits = news_alerts(conn)
|
||||
for h in hits:
|
||||
emit('快讯关键词告警', h)
|
||||
|
||||
# 3) 盘后(15:10 后):龙虎榜 + 日报
|
||||
if hm >= 1510 and not state.get('postmarket_done'):
|
||||
n = save_lhb_today(conn)
|
||||
sh = conn.execute("SELECT trade_date, close FROM stock_daily WHERE ts_code='sh000001' "
|
||||
"ORDER BY trade_date DESC LIMIT 1").fetchone()
|
||||
emit('收盘日报', {'龙虎榜新增': n, '上证最新收盘': sh})
|
||||
state['postmarket_done'] = True
|
||||
|
||||
# 4) 日切重置
|
||||
if state.get('date') != now.strftime('%Y-%m-%d'):
|
||||
state.clear()
|
||||
state['date'] = now.strftime('%Y-%m-%d')
|
||||
load_priors()
|
||||
emit('日切', {'date': state['date']})
|
||||
|
||||
|
||||
def main():
|
||||
print('=== JQuant 市场时间线实时分析器 ===')
|
||||
print('数据库:', DB)
|
||||
print('输出流:', FEED)
|
||||
print('Ctrl+C 停止', flush=True)
|
||||
conn = db_conn()
|
||||
load_priors()
|
||||
state = {'date': datetime.now().strftime('%Y-%m-%d')}
|
||||
interval = 60
|
||||
while True:
|
||||
try:
|
||||
one_cycle(conn, state)
|
||||
except KeyboardInterrupt:
|
||||
print('收到停止指令,退出', flush=True)
|
||||
break
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
time.sleep(10)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# storage package
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
SQLite + CSV 双写存储层
|
||||
表结构按"接口族"隔离,方便按需降级
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent.parent / "data" / "a_stock.db"
|
||||
CSV_DIR = Path(__file__).parent.parent.parent / "data" / "raw"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 连接管理器
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def to_csv(df: pd.DataFrame, table: str, ts: Optional[str] = None):
|
||||
"""入库后同步写一份 CSV 备份(方便溯源)"""
|
||||
if df is None or df.empty:
|
||||
return
|
||||
ts = ts or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
CSV_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = CSV_DIR / f"{table}_{ts}.csv"
|
||||
df.to_csv(path, index=False, encoding="utf-8-sig")
|
||||
return path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Schema 创建
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCHEMA_SQL = """
|
||||
-- 交易日历
|
||||
CREATE TABLE IF NOT EXISTS trade_calendar (
|
||||
trade_date TEXT PRIMARY KEY,
|
||||
is_trade_day INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- A股日K线(前复权)
|
||||
CREATE TABLE IF NOT EXISTS stock_daily (
|
||||
ts_code TEXT NOT NULL,
|
||||
trade_date TEXT NOT NULL,
|
||||
open REAL,
|
||||
high REAL,
|
||||
low REAL,
|
||||
close REAL,
|
||||
volume REAL,
|
||||
amount REAL,
|
||||
PRIMARY KEY (ts_code, trade_date)
|
||||
);
|
||||
|
||||
-- 龙虎榜
|
||||
CREATE TABLE IF NOT EXISTS lhb_daily (
|
||||
trade_date TEXT NOT NULL,
|
||||
ts_code TEXT NOT NULL,
|
||||
name TEXT,
|
||||
close REAL,
|
||||
pct_change REAL,
|
||||
amount REAL,
|
||||
reason TEXT,
|
||||
buy_seats INTEGER,
|
||||
sell_seats INTEGER,
|
||||
net_amount REAL,
|
||||
PRIMARY KEY (trade_date, ts_code, reason)
|
||||
);
|
||||
|
||||
-- 7x24 快讯流(精确到秒)
|
||||
CREATE TABLE IF NOT EXISTS news_flash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
event_time TEXT NOT NULL,
|
||||
source TEXT,
|
||||
inserted_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 每日大事提醒(政策级)
|
||||
CREATE TABLE IF NOT EXISTS macro_event (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_date TEXT NOT NULL,
|
||||
event_time TEXT,
|
||||
title TEXT NOT NULL,
|
||||
event_type INTEGER,
|
||||
source TEXT,
|
||||
actual TEXT,
|
||||
forecast TEXT,
|
||||
prev TEXT,
|
||||
inserted_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- 全网财经新闻
|
||||
CREATE TABLE IF NOT EXISTS news_cn (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
pub_date TEXT NOT NULL,
|
||||
author TEXT,
|
||||
source TEXT,
|
||||
url TEXT,
|
||||
keywords TEXT,
|
||||
inserted_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 北向/主力资金流(日频)
|
||||
CREATE TABLE IF NOT EXISTS money_flow (
|
||||
ts_code TEXT NOT NULL,
|
||||
trade_date TEXT NOT NULL,
|
||||
main_net_in REAL,
|
||||
in_flow REAL,
|
||||
out_flow REAL,
|
||||
amount REAL,
|
||||
pct_change REAL,
|
||||
PRIMARY KEY (ts_code, trade_date)
|
||||
);
|
||||
|
||||
-- 全球指数(日频,外盘收盘后入库)
|
||||
CREATE TABLE IF NOT EXISTS global_index (
|
||||
index_code TEXT NOT NULL,
|
||||
trade_date TEXT NOT NULL,
|
||||
close REAL,
|
||||
pct_change REAL,
|
||||
PRIMARY KEY (index_code, trade_date)
|
||||
);
|
||||
|
||||
-- 采集记录(断点续采用)
|
||||
CREATE TABLE IF NOT EXISTS fetch_log (
|
||||
source TEXT NOT NULL,
|
||||
param TEXT,
|
||||
last_ts TEXT,
|
||||
last_date TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (source, param)
|
||||
);
|
||||
"""
|
||||
|
||||
def init_db():
|
||||
with get_conn() as conn:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
print(f"[OK] Database initialized: {DB_PATH}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 通用 upsert
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_df(
|
||||
df: pd.DataFrame,
|
||||
table: str,
|
||||
pk_cols: list[str] = None, # 暂未使用,to_sql(replace) 已处理主键冲突
|
||||
csv备份: bool = True
|
||||
) -> int:
|
||||
"""主键冲突时 replace,等效于 upsert"""
|
||||
if df is None or df.empty:
|
||||
return 0
|
||||
# 自动追加 inserted_at / updated_at
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if "inserted_at" in df.columns:
|
||||
df["inserted_at"] = now
|
||||
elif table in ("news_flash", "macro_event", "news_cn"):
|
||||
df["inserted_at"] = now
|
||||
|
||||
with get_conn() as conn:
|
||||
# sqlite 不支持 df 直接 execute,需要逐行或用 executemany
|
||||
df.to_sql(table, conn, if_exists="replace", index=False)
|
||||
row_count = len(df)
|
||||
|
||||
if csv备份:
|
||||
to_csv(df, table)
|
||||
print(f" [OK] {table}: {row_count} rows upserted")
|
||||
return row_count
|
||||
|
||||
|
||||
def upsert_log(source: str, param: str, last_ts: str = None, last_date: str = None):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO fetch_log (source, param, last_ts, last_date, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source, param) DO UPDATE SET
|
||||
last_ts = COALESCE(excluded.last_ts, last_ts),
|
||||
last_date = COALESCE(excluded.last_date, last_date),
|
||||
updated_at = excluded.updated_at
|
||||
""", (source, param, last_ts, last_date, now))
|
||||
|
||||
|
||||
def get_last_fetch(source: str, param: str = "") -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT last_ts, last_date, updated_at FROM fetch_log WHERE source=? AND param=?",
|
||||
(source, param)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {"last_ts": row[0], "last_date": row[1], "updated_at": row[2]}
|
||||
return {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 保历史的 upsert(INSERT OR REPLACE,不清空表)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IDENT_RE = __import__('re').compile(r'^[A-Za-z0-9_]+$')
|
||||
|
||||
|
||||
def _safe_ident(name: str) -> str:
|
||||
"""SQL 标识符白名单校验(列名/表名不可参数化,只允许字母数字下划线)"""
|
||||
if not _IDENT_RE.match(str(name)):
|
||||
raise ValueError("illegal identifier: %r" % (name,))
|
||||
return str(name)
|
||||
|
||||
|
||||
def ensure_index(table: str, cols: list) -> None:
|
||||
"""按冲突列建唯一索引(幂等)"""
|
||||
tab = _safe_ident(table)
|
||||
col_ids = [_safe_ident(c) for c in cols]
|
||||
idx = "ux_{}_{}".format(tab, "_".join(col_ids))
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS {} ON {}({})".format(
|
||||
idx, tab, ",".join(col_ids)))
|
||||
|
||||
|
||||
def upsert_rows(df, table: str, conflict_cols: list) -> int:
|
||||
"""INSERT OR REPLACE 按 conflict_cols 去重,保留既有历史。值全部走 ? 占位符。"""
|
||||
if df is None or df.empty:
|
||||
return 0
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if "inserted_at" in df.columns:
|
||||
df["inserted_at"] = now
|
||||
tab = _safe_ident(table)
|
||||
col_ids = [_safe_ident(c) for c in df.columns]
|
||||
sql = "INSERT OR REPLACE INTO {}({}) VALUES ({})".format(
|
||||
tab, ",".join(col_ids), ",".join("?" * len(col_ids)))
|
||||
rows = [tuple(None if (isinstance(v, float) and v != v) else v
|
||||
for v in tup) for tup in df.itertuples(index=False, name=None)]
|
||||
with get_conn() as conn:
|
||||
conn.executemany(sql, rows)
|
||||
print(" [OK] {}: {} rows upserted (conflict on {})".format(tab, len(rows), conflict_cols))
|
||||
return len(rows)
|
||||
Reference in New Issue
Block a user