提交
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""大盘行情总览(主页展示)——两层结构。
|
||||
|
||||
- live 层:腾讯免费实时行情(qt.gtimg.cn,一次 GET 拿全部指数现价/涨跌幅),
|
||||
- live 层:腾讯免费实时行情(qt.gtimg.cn,一次 GET 拿 A股/美股指数现价/涨跌幅)+
|
||||
东财 push2(日韩指数,腾讯 s_ 前缀不覆盖,实测 s_jp*/s_kr* 均无符号),
|
||||
进程内缓存 30s(成功)/ 15s 负缓存(失败,防止接口抖动持续拖慢请求)。
|
||||
- EOD 层:tushare 收盘数据 —— A 股指数 pro.index_daily、全球指数 pro.index_global
|
||||
(45 日 spark 走势)+ 两市统计/成交额历史 pro.daily_info。收盘数据一天一变,
|
||||
@@ -28,7 +29,7 @@ from ..config import settings
|
||||
from .sync_utils import d8_iso, f_clean
|
||||
|
||||
# (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序
|
||||
# 首页聚焦中美(港股/国际指数在 /indexes 国际指数页);标普500 腾讯符号是 s_usINX(不是 s_usSPX)
|
||||
# 首页聚焦中美 + 日韩(其余国际指数在 /indexes 国际指数页);标普500 腾讯符号是 s_usINX(不是 s_usSPX)
|
||||
MARKET_INDEXES: list[tuple[str, str, str, str]] = [
|
||||
("000001.SH", "上证指数", "cn", "s_sh000001"),
|
||||
("399001.SZ", "深证成指", "cn", "s_sz399001"),
|
||||
@@ -37,20 +38,36 @@ MARKET_INDEXES: list[tuple[str, str, str, str]] = [
|
||||
("DJI", "道琼斯", "us", "s_usDJI"),
|
||||
("IXIC", "纳斯达克", "us", "s_usIXIC"),
|
||||
("SPX", "标普500", "us", "s_usINX"),
|
||||
("N225", "日经225", "apac", ""), # 日韩实时走东财(_APAC_SECIDS),腾讯符号留空
|
||||
("KS11", "韩国KOSPI", "apac", ""),
|
||||
]
|
||||
|
||||
# 深证综指:不展示,仅取其 f[7](深市全市成交额,万元)
|
||||
_TENCENT_SZ_TOTAL = "s_sz399106"
|
||||
_TENCENT_MAP = {ts_code: sym for ts_code, _, _, sym in MARKET_INDEXES}
|
||||
_TENCENT_MAP = {ts_code: sym for ts_code, _, _, sym in MARKET_INDEXES if sym}
|
||||
_TENCENT_URL = "http://qt.gtimg.cn/q=" + ",".join([*_TENCENT_MAP.values(), _TENCENT_SZ_TOTAL])
|
||||
|
||||
# 日韩指数实时源:东财 push2 ulist 一次 GET(主站 + 延迟镜像双 host)。腾讯 s_ 前缀不
|
||||
# 覆盖日韩(实测均返回 pv_none_match);新浪 int_kospi 为空且 int_* 行情明显滞后
|
||||
# (实测 DJI 差价 ~6000 点),故不用。
|
||||
_APAC_SECIDS = {"N225": "100.N225", "KS11": "100.KS11"}
|
||||
_EM_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"} # 同 etf_provider
|
||||
_EM_HOSTS = ["https://push2.eastmoney.com", "https://push2delay.eastmoney.com"]
|
||||
_EM_PATH = ("/api/qt/ulist.np/get?fltt=2&invt=2&fields=f2,f3,f4,f12,f14&secids="
|
||||
+ ",".join(_APAC_SECIDS.values()))
|
||||
# 指数 code -> live 行情键(em: 前缀与腾讯符号隔离),merge 时查表
|
||||
_LIVE_SYM_MAP: dict[str, str] = {**_TENCENT_MAP, **{c: f"em:{c}" for c in _APAC_SECIDS}}
|
||||
|
||||
_SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
|
||||
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark)
|
||||
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频
|
||||
_EOD_KEY = "market_overview:eod:v3" # v3:首页列表移除港股(移入国际指数页),与 v2 隔离
|
||||
_EOD_KEY = "market_overview:eod:v4" # v4:新增日韩指数(N225/KS11),与 v3 隔离
|
||||
_AMOUNT_HIST_CAL_DAYS = 190 # 成交额历史的日历日窗口(≈128 交易日)
|
||||
_AMOUNT_HIST_BARS = 120 # 输出的柱数(取尾部)
|
||||
_LIVE_FAIL_TTL = 15.0 # live 层失败负缓存(秒)
|
||||
_LIVE_FAIL_TTL = 15.0 # 腾讯 live 层失败负缓存(秒)
|
||||
_EM_FAIL_TTL = 90.0 # 东财日韩失败负缓存(秒):长于 live 缓存 30s,
|
||||
# 持续故障时最多每 90s 才有一次超时等待,其余刷新直接跳过
|
||||
|
||||
|
||||
class MarketOverviewError(RuntimeError):
|
||||
@@ -69,7 +86,9 @@ def _get_pro():
|
||||
# ======================= live 层:腾讯实时行情 =======================
|
||||
|
||||
_live: dict = {"at": 0.0, "quotes": None} # 进程内缓存(monotonic 时钟)
|
||||
_live_error: str | None = None # 最近一次实时拉取失败的原因(成功后清空)
|
||||
_live_error: str | None = None # 最近一次腾讯实时拉取失败的原因(成功后清空)
|
||||
_live_em_error: str | None = None # 最近一次东财日韩实时拉取失败的原因(成功后清空)
|
||||
_em_state: dict = {"at": 0.0, "ok": None, "host": 0} # 东财日韩负缓存 + 最近成功 host 索引
|
||||
|
||||
|
||||
async def _fetch_live_http() -> dict[str, dict]:
|
||||
@@ -112,23 +131,91 @@ async def _fetch_live_http() -> dict[str, dict]:
|
||||
return quotes
|
||||
|
||||
|
||||
def _parse_em_ulist(payload: dict) -> dict[str, dict]:
|
||||
"""ulist JSON -> {em:code: {name, price, change, pct, amount_wan}}。fltt=2 下 f2/f3/f4
|
||||
直接是浮点;未开盘等场景字段是 "-",解析为 None。"""
|
||||
diff = (payload.get("data") or {}).get("diff") or []
|
||||
quotes: dict[str, dict] = {}
|
||||
for row in diff:
|
||||
code = str(row.get("f12") or "")
|
||||
if code not in _APAC_SECIDS:
|
||||
continue
|
||||
|
||||
def _num(v) -> float | None:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
quotes[f"em:{code}"] = {
|
||||
"name": row.get("f14"),
|
||||
"price": _num(row.get("f2")),
|
||||
"change": _num(row.get("f4")),
|
||||
"pct": _num(row.get("f3")),
|
||||
"amount_wan": None, # 日韩成交额口径不同且未使用,不取
|
||||
}
|
||||
if not quotes:
|
||||
raise RuntimeError("东财日韩行情响应为空或无法解析")
|
||||
return quotes
|
||||
|
||||
|
||||
async def _fetch_live_apac_http() -> dict[str, dict]:
|
||||
"""日韩指数实时(东财 ulist 一次 GET,主站/延迟镜像按序重试,粘住最近成功的 host)。
|
||||
|
||||
坑(同 etf_provider 实测):push2 主站短连发几次会直接断连,push2delay 镜像稳;
|
||||
本机 v2rayN 系统代理(127.0.0.1:10808)对 push2 的 https CONNECT 隧道断连(curl -x
|
||||
同样失败,腾讯 http:// 却正常)——必须带浏览器 UA 且 trust_env=False 直连。"""
|
||||
start = _em_state.get("host", 0)
|
||||
last_err: Exception | None = None
|
||||
for i in range(len(_EM_HOSTS)):
|
||||
url = _EM_HOSTS[(start + i) % len(_EM_HOSTS)] + _EM_PATH
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.tencent_quote_timeout, headers=_EM_HEADERS, trust_env=False,
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
quotes = _parse_em_ulist(payload)
|
||||
_em_state["host"] = (start + i) % len(_EM_HOSTS) # 粘住成功 host
|
||||
return quotes
|
||||
except Exception as e: # noqa: BLE001 —— 换镜像整重来
|
||||
last_err = e
|
||||
raise RuntimeError(f"东财日韩行情双镜像均失败: {str(last_err)[:60]}")
|
||||
|
||||
|
||||
async def _fetch_live() -> dict[str, dict] | None:
|
||||
"""实时行情(进程内缓存);任何失败返回 None,上层降级 EOD。"""
|
||||
global _live_error
|
||||
"""实时行情(进程内缓存);腾讯与东财各自独立降级,全失败返回 None,上层降级 EOD。
|
||||
东财有独立负缓存(_EM_FAIL_TTL):持续故障时后续刷新直接跳过,不吃超时等待。"""
|
||||
global _live_error, _live_em_error
|
||||
now = time.monotonic()
|
||||
age = now - _live["at"]
|
||||
if _live["quotes"] is not None and age < settings.market_live_ttl:
|
||||
return _live["quotes"]
|
||||
if _live["quotes"] is None and _live["at"] > 0 and age < _LIVE_FAIL_TTL:
|
||||
return None # 负缓存:刚失败过,短时间内不再打腾讯
|
||||
return None # 负缓存:刚失败过,短时间内不再打行情接口
|
||||
|
||||
quotes: dict[str, dict] = {}
|
||||
try:
|
||||
quotes = await _fetch_live_http()
|
||||
quotes.update(await _fetch_live_http())
|
||||
_live_error = None
|
||||
except Exception as e: # noqa: BLE001 —— 实时层是锦上添花,失败不拖垮整包
|
||||
_live.update(at=now, quotes=None)
|
||||
_live_error = f"实时行情: {str(e)[:60]}"
|
||||
if _em_state["ok"] is False and now - _em_state["at"] < _EM_FAIL_TTL:
|
||||
pass # 东财刚失败过:负缓存期内跳过(_live_em_error 保留上次原因)
|
||||
else:
|
||||
try:
|
||||
quotes.update(await _fetch_live_apac_http())
|
||||
_live_em_error = None
|
||||
_em_state.update(at=now, ok=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_live_em_error = f"日韩实时行情: {str(e)[:60]}"
|
||||
_em_state.update(at=now, ok=False)
|
||||
|
||||
if not quotes:
|
||||
_live.update(at=now, quotes=None)
|
||||
return None
|
||||
_live.update(at=now, quotes=quotes)
|
||||
_live_error = None
|
||||
return quotes
|
||||
|
||||
|
||||
@@ -315,7 +402,7 @@ async def fetch_overview(is_trading_day: bool | None = None) -> dict:
|
||||
indexes: list[dict] = []
|
||||
for it in eod["indexes"]:
|
||||
out = dict(it)
|
||||
sym = _TENCENT_MAP.get(it["code"])
|
||||
sym = _LIVE_SYM_MAP.get(it["code"])
|
||||
q = live.get(sym) if (live and sym) else None
|
||||
if q and q.get("price") is not None:
|
||||
# spark 永远来自 EOD(末点是上一收盘点,与实时价并存是已知的装饰性差异,不改历史序列)
|
||||
@@ -345,6 +432,8 @@ async def fetch_overview(is_trading_day: bool | None = None) -> dict:
|
||||
errors.append(_eod_refresh_error)
|
||||
if _live_error:
|
||||
errors.append(_live_error)
|
||||
if _live_em_error:
|
||||
errors.append(_live_em_error)
|
||||
|
||||
return {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""全市场数据同步(未复权,写入 candles 全量底座)。
|
||||
|
||||
设计:trade_cal 取近 N 个交易日 -> 逐日 pro.daily(trade_date=...) 一次返回全市场当日数据
|
||||
-> upsert 进 candles(不复权底座,ON CONFLICT 幂等);daily_basic 仅同步最新交易日到
|
||||
DailySnapshot(市值/PE/PB/换手率等截面字段)。
|
||||
-> upsert 进 candles(不复权底座,ON CONFLICT 幂等);daily_basic 同步最新交易日到
|
||||
DailySnapshot(市值/PE/PB 等截面字段),并复用该次调用把换手率回写 candles.turnover
|
||||
(历史缺漏日由自愈循环补,见 _run_sync 第 3.5 步)。
|
||||
同步为进程内后台任务(MVP 不引入任务队列),前端轮询 /api/screener/sync/status。
|
||||
|
||||
daily 与 daily_basic 分步独立落库:daily_basic 积分不足时快照仍可用,错误写入状态不中断任务。
|
||||
@@ -14,7 +15,7 @@ import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, func, insert, select
|
||||
from sqlalchemy import delete, func, insert, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -244,7 +245,7 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se
|
||||
"open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"],
|
||||
"volume": r["vol"] * 100.0, # 手 -> 股
|
||||
"amount": (r["amount"] * 1000.0) if r["amount"] is not None else None, # 千元 -> 元
|
||||
"turnover": None, # 换手率另由 daily_basic 快照维护
|
||||
"turnover": None, # 换手率由 _run_sync 第 3/3.5 步从 daily_basic 回写
|
||||
}
|
||||
for r in rows
|
||||
if plain_code(r["ts_code"]) in listed
|
||||
@@ -269,10 +270,65 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _run_sync(days: int, force: bool) -> None:
|
||||
"""后台任务主体:stock_basic -> 逐日日线 -> 最新交易日快照。异常写状态。
|
||||
_TURNOVER_FLOOR = "20000104" # daily_basic 最早覆盖日,更早的交易日拉了也是空
|
||||
|
||||
daily_basic 只拉最新交易日(快照条件仅作用于最新截面,且低积分 token 限频 1 次/分钟)。
|
||||
|
||||
async def _backfill_turnover_day(session: AsyncSession, basic_rows: list[dict], d_str: str) -> int:
|
||||
"""把 daily_basic 的 turnover_rate 回写 candles.turnover(只动该列,幂等)。
|
||||
|
||||
basic_rows 复用 _fetch_basic 的返回(零额外 API 调用);单条 UPDATE...FROM
|
||||
unnest 批量写回,ETF 等不在 daily_basic 的行不会命中。
|
||||
"""
|
||||
syms = [plain_code(r["ts_code"]) for r in basic_rows if r["turnover_rate"] is not None]
|
||||
trs = [r["turnover_rate"] for r in basic_rows if r["turnover_rate"] is not None]
|
||||
if not syms:
|
||||
return 0
|
||||
res = await session.execute(
|
||||
text("UPDATE candles AS c SET turnover = v.t "
|
||||
"FROM unnest(CAST(:syms AS text[]), CAST(:trs AS float8[])) AS v(sym, t) "
|
||||
"WHERE c.symbol = v.sym AND c.timeframe = '1d' AND c.ts = :ts"),
|
||||
{"syms": syms, "trs": trs, "ts": _parse_d(d_str)},
|
||||
)
|
||||
await session.commit()
|
||||
return res.rowcount or 0
|
||||
|
||||
|
||||
async def _backfill_turnover_gaps(pro) -> int:
|
||||
"""换手率全范围自愈:按日聚合在市股票的换手覆盖,过半缺失的交易日逐日拉
|
||||
daily_basic 补齐,返回处理的缺口天数。
|
||||
|
||||
夜间同步与手动同步共用本函数(唯一入口,幂等可断点续跑——补完的日子下轮
|
||||
不再命中),正常无缺口时零 API 调用。只统计在市股票(与 _recent_day_counts
|
||||
同口径):ETF/DEMO 行 daily_basic 天然不覆盖,混进来会把健康日误判成缺换手。
|
||||
"""
|
||||
from ..db import async_session # 延迟导入避免循环
|
||||
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(func.date(Candle.ts), func.count(), func.count(Candle.turnover))
|
||||
.where(Candle.timeframe == "1d", Candle.ts >= _parse_d(_TURNOVER_FLOOR),
|
||||
Candle.symbol.in_(select(StockBasic.symbol).where(StockBasic.list_status == "L")))
|
||||
.group_by(func.date(Candle.ts))
|
||||
.order_by(func.date(Candle.ts))
|
||||
)).all()
|
||||
gaps = [d.strftime("%Y%m%d") for d, total, done in rows if total and done < total // 2]
|
||||
for i, d_str in enumerate(gaps, 1):
|
||||
_sync_state["step"] = f"正在回补 {d_str} 换手率({i}/{len(gaps)})"
|
||||
try:
|
||||
basic_rows = await asyncio.to_thread(_fetch_basic, pro, d_str)
|
||||
if basic_rows:
|
||||
async with async_session() as session:
|
||||
await _backfill_turnover_day(session, basic_rows, d_str)
|
||||
except Exception: # noqa: BLE001 —— 单日失败不中断,下次同步再试
|
||||
log.warning("换手率回补 %s 失败(下次同步再试)", d_str, exc_info=True)
|
||||
return len(gaps)
|
||||
|
||||
|
||||
async def _run_sync(days: int, force: bool) -> None:
|
||||
"""后台任务主体:stock_basic -> 逐日日线 -> 最新交易日快照 + 换手率回写/自愈。异常写状态。
|
||||
|
||||
daily_basic 只拉最新交易日(快照条件仅作用于最新截面,且低积分 token 限频 1 次/分钟);
|
||||
历史缺口的换手率由 _backfill_turnover_gaps 统一补齐,夜间/手动同步共用同一管道。
|
||||
"""
|
||||
from ..db import async_session # 延迟导入避免循环
|
||||
|
||||
@@ -343,6 +399,7 @@ async def _run_sync(days: int, force: bool) -> None:
|
||||
async with async_session() as session:
|
||||
latest_dt = await session.scalar(select(func.max(Candle.ts)))
|
||||
latest = latest_dt.strftime("%Y%m%d") if latest_dt else None
|
||||
basic_rows: list[dict] = []
|
||||
if latest:
|
||||
async with async_session() as session:
|
||||
have_snap = force or latest not in await _existing_dates(session, DailySnapshot)
|
||||
@@ -353,6 +410,23 @@ async def _run_sync(days: int, force: bool) -> None:
|
||||
async with async_session() as session:
|
||||
await _replace_day(session, DailySnapshot, basic_rows, latest)
|
||||
|
||||
# 3.5) 换手率回写:日线同步不写 turnover(daily_basic 才有)——快照那次调用
|
||||
# 顺手回写最新日(零额外 API 调用);历史缺口统一由 _backfill_turnover_gaps
|
||||
# 全范围扫补,夜间/手动同步共用同一管道
|
||||
if latest and basic_rows:
|
||||
_sync_state["step"] = f"正在回写 {latest} 换手率"
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await _backfill_turnover_day(session, basic_rows, latest)
|
||||
except Exception: # noqa: BLE001 —— 回写失败不影响快照,缺口由自愈兜底
|
||||
log.warning("换手率回写 %s 失败(下次同步自愈)", latest, exc_info=True)
|
||||
try:
|
||||
n_gap = await _backfill_turnover_gaps(pro)
|
||||
if n_gap:
|
||||
log.info("换手率自愈补齐 %d 个交易日", n_gap)
|
||||
except Exception: # noqa: BLE001 —— 自愈失败不阻断同步收尾,下次再试
|
||||
log.warning("换手率自愈失败(下次同步再试)", exc_info=True)
|
||||
|
||||
# candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效)
|
||||
await cache.bump_version("candles")
|
||||
# 预热统计缓存:同步任务自己付一次重聚合(>10s)。SWR 下轮询方不等待——
|
||||
|
||||
Reference in New Issue
Block a user