diff --git a/README.md b/README.md index 3f5dd12..b7dddb0 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ ```bash # 终端 1 —— 后端 API(http://localhost:8000) cd backend -uv run uvicorn app.main:app --reload --port 8000 +env -u SSLKEYLOGFILE uv run uvicorn app.main:app --reload --port 8000 # 终端 2 —— 前端开发服务器(http://localhost:5173) cd frontend pnpm dev ``` +> `env -u SSLKEYLOGFILE` 是本机(Windows)必需的:用户环境变量 `SSLKEYLOGFILE` 值开头混有不可见控制符,asyncpg 建连即崩。详见下文「快速开始(开发模式)」与「常见问题」。 + 首次运行前先安装依赖:后端 `uv sync`(backend 下)、前端 `pnpm install`(frontend 下)。 浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。 @@ -115,12 +117,25 @@ pip install uv ### 1) 后端 +实际启动命令(venv 解释器直启,日志重定向到仓库根 `backend_run.log`,改代码自动热重载): + ```bash cd backend -uv sync # 创建 .venv 并安装依赖(首次) -uv run uvicorn app.main:app --reload --port 8000 +uv sync # 创建 .venv 并安装依赖(仅首次) +env -u SSLKEYLOGFILE .venv/Scripts/python.exe -m uvicorn app.main:app --reload --port 8000 > ../backend_run.log 2>&1 ``` +看日志:`tail -f backend_run.log`;确认起没起:`curl http://localhost:8000/api/health`。 + +等价的 `uv run` 写法(不带日志重定向,直接打到当前终端): + +```bash +cd backend +env -u SSLKEYLOGFILE uv run uvicorn app.main:app --reload --port 8000 +``` + +- **`env -u SSLKEYLOGFILE`(本机 Windows 必需)**:用户环境变量 `SSLKEYLOGFILE` 的值开头混有 U+202A 不可见控制符,asyncpg 建连时执行 `ssl.keylog_filename` 直接抛 `OSError: [Errno 22]`,uvicorn 启动即崩。Git Bash 下用 `env -u` 剔除即可;根治可 `setx SSLKEYLOGFILE "C:\Users\cirry\Desktop\fhzg.log"`(重开终端后不再需要前缀)。验证:`python -c "import os; print(repr(os.environ.get('SSLKEYLOGFILE')))"`。 +- **Windows `--reload` 僵死**:watcher 偶尔改文件不重载且日志无 Reloading 行,此时只能杀进程树重启——`netstat -ano | grep :8000` 找 PID,`taskkill //PID //T //F`。 - 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。 - 交互式 API 文档:http://localhost:8000/docs @@ -306,6 +321,8 @@ volumes: { pgdata: {} } - **真实标的首次回测慢/拉取失败**:首次需联网请求 Tushare(数秒)。Tushare 对 `adj_factor` 限频(每小时 1 次),触发时自动退化为不复权日线,不影响回测。数据已本地缓存,二次回测秒出。若 Tushare 积分不足,`uv add akshare` 后将自动用 AKShare 兜底。 - **高价股(如茅台)回测 0 信号**:一手太贵买不起。默认初始资金已设为 100 万;如仍不够,在表单调高「初始资金」。 - **Python 3.14 兼容**:MVP 依赖(fastapi/sqlalchemy/pandas/numpy)均已支持;指标用纯 Python 实现,不依赖 TA-Lib 的 C 库,Windows 免编译。 +- **后端启动即崩,栈在 asyncpg 深处(`OSError: [Errno 22] Invalid argument`)**:是本机环境变量 `SSLKEYLOGFILE` 值里混入的 U+202A 不可见控制符所致,不是数据库问题。Git Bash 下启动命令前加 `env -u SSLKEYLOGFILE`;或 `setx SSLKEYLOGFILE "C:\Users\cirry\Desktop\fhzg.log"` 重写该变量后重开终端。 +- **uvicorn `--reload` 改代码不生效**:Windows 下 watcher 会僵死(日志无 Reloading 行,touch 无效)。`netstat -ano | grep :8000` 找到 PID 后 `taskkill //PID //T //F` 杀进程树重启。 --- diff --git a/backend/app/api.py b/backend/app/api.py index 98919eb..dbdf2c1 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -44,6 +44,7 @@ from .models import ( Candle, ScreenerQuery, StockBasic, + TradeCalendar, UserPreference, UserTrade, WatchlistItem, @@ -387,13 +388,22 @@ async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response @router.get("/market/overview", response_model=MarketOverviewResponse) -async def get_market_overview() -> MarketOverviewResponse: - """主页大盘总览:A 股 + 港美指数最近收盘(含迷你走势),沪深两市市值/成交统计。 - - 收盘口径(token 无实时权限),展示时标注交易日;部分来源失败不影响其余。 +async def get_market_overview(session: AsyncSession = Depends(get_session)) -> MarketOverviewResponse: + """主页大盘总览:A 股 + 港美指数实时价(腾讯)叠加收盘历史走势(tushare), + 沪深两市市值/成交统计 + 成交额历史。部分来源失败不影响其余。 """ + # 今日是否 A 股交易日(trade_date 为 String(8) unique 索引,等值查亚毫秒级); + # DB 不可用时传 None,fetch_overview 回退 weekday 启发式 + today8 = datetime.now().strftime("%Y%m%d") + is_trading_day: bool | None = None try: - data = await fetch_overview() + is_trading_day = bool(await session.scalar( + select(TradeCalendar.id).where(TradeCalendar.trade_date == today8).limit(1) + )) + except Exception: # noqa: BLE001 —— 判定失败只影响「今日盘中 bar」是否追加 + pass + try: + data = await fetch_overview(is_trading_day=is_trading_day) except MarketOverviewError as e: raise HTTPException(status_code=503, detail=str(e)) from e return MarketOverviewResponse(**data) diff --git a/backend/app/config.py b/backend/app/config.py index 001b970..0b8b819 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -38,7 +38,12 @@ class Settings(BaseSettings): redis_url: str = "" stocks_cache_ttl: int = 300 # 股票列表缓存秒数(行情列允许最多滞后这么多秒) facets_cache_ttl: int = 3600 # 行业/地域筛选项缓存秒数(stock_basic 很少变) - market_overview_ttl: int = 300 # 主页大盘总览缓存秒数(收盘口径数据,无需更勤) + # ---- 主页大盘总览(live 层腾讯实时 + EOD 层 tushare 收盘,见 data/market_overview.py)---- + market_live_ttl: int = 30 # 腾讯实时行情进程内缓存秒数(成功) + market_eod_fresh_ttl: int = 14400 # EOD 层新鲜期(秒),过期走 SWR(先返旧值后台刷新) + market_eod_redis_ttl: int = 86400 # EOD 层 Redis TTL(SWR 陈旧值的兜底存活期) + tencent_quote_timeout: float = 3.0 # 腾讯行情 HTTP 超时(秒) + sync_stats_redis_ttl: int = 43200 # 同步状态重聚合的 Redis 缓存秒数(key 带版本号,同步完成即失效) # ---- LLM(智能选股的自然语言解析;DeepSeek,OpenAI 兼容协议,可换任意兼容网关)---- llm_base_url: str = "https://api.deepseek.com" diff --git a/backend/app/data/market_overview.py b/backend/app/data/market_overview.py index 45d2d81..408dfbb 100644 --- a/backend/app/data/market_overview.py +++ b/backend/app/data/market_overview.py @@ -1,40 +1,58 @@ -"""大盘行情总览(主页展示)。 +"""大盘行情总览(主页展示)——两层结构。 -- A 股指数走 pro.index_daily,全球指数走 pro.index_global(均为收盘口径,晚间更新; - 本 token 无 rt_idx_k 实时权限,故展示「最近交易日收盘」并标注日期)。 -- 两市统计走 pro.daily_info:沪市取 SH_A、深市取 SZ_MARKET 汇总出 - 总市值 / 流通市值 / 成交额(单位亿元,接口原生口径)。 -- 整包结果写 Redis 缓存(TTL 可配);单个指数拉取失败只是跳过(errors 里注明), - 全部失败才抛 MarketOverviewError —— 主页行情是锦上添花,不拖垮整页。 +- live 层:腾讯免费实时行情(qt.gtimg.cn,一次 GET 拿全部指数现价/涨跌幅), + 进程内缓存 30s(成功)/ 15s 负缓存(失败,防止接口抖动持续拖慢请求)。 +- EOD 层:tushare 收盘数据 —— A 股指数 pro.index_daily、全球指数 pro.index_global + (45 日 spark 走势)+ 两市统计/成交额历史 pro.daily_info。收盘数据一天一变, + 进程内新鲜期 4h + Redis 兜底 24h,过期走 SWR(先返旧值,后台刷新,永不阻塞用户)。 +- merge:实时价覆盖 close/change/pct_chg(realtime=True),拿不到实时值的指数回退 + 收盘口径;今日为交易日且 EOD 尚未含今日时,用腾讯全市口径成交额追加盘中 bar。 + +口径说明(daily_info 板块行,实测 2026-09): +- 沪市 SH_MARKET = 主板A + 科创板(SH_STAR) + B股,不含基金(SH_FUND); + 旧口径 SH_A 漏科创板(日均 ~2500 亿),是成交额偏小的根因。 +- 深市 SZ_MARKET = 主板 + 创业板,全部为股票。 +- 任何单个来源失败只是跳过(errors 里注明),全部失败才抛 MarketOverviewError。 """ from __future__ import annotations import asyncio import math +import time from datetime import date, datetime, timedelta +import httpx import pandas as pd from .. import cache from ..config import settings -# (代码, 名称, 地区) —— 展示顺序即列表顺序 -MARKET_INDEXES: list[tuple[str, str, str]] = [ - ("000001.SH", "上证指数", "cn"), - ("399001.SZ", "深证成指", "cn"), - ("399006.SZ", "创业板指", "cn"), - ("000688.SH", "科创50", "cn"), - ("HSI", "恒生指数", "hk"), - ("HKTECH", "恒生科技", "hk"), - ("DJI", "道琼斯", "us"), - ("IXIC", "纳斯达克", "us"), - ("SPX", "标普500", "us"), +# (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序 +# 标普500 腾讯符号是 s_usINX(不是 s_usSPX);恒生科技是 s_hkHSTECH(不是 HSTECH) +MARKET_INDEXES: list[tuple[str, str, str, str]] = [ + ("000001.SH", "上证指数", "cn", "s_sh000001"), + ("399001.SZ", "深证成指", "cn", "s_sz399001"), + ("399006.SZ", "创业板指", "cn", "s_sz399006"), + ("000688.SH", "科创50", "cn", "s_sh000688"), + ("HSI", "恒生指数", "hk", "s_hkHSI"), + ("HKTECH", "恒生科技", "hk", "s_hkHSTECH"), + ("DJI", "道琼斯", "us", "s_usDJI"), + ("IXIC", "纳斯达克", "us", "s_usIXIC"), + ("SPX", "标普500", "us", "s_usINX"), ] +# 深证综指:不展示,仅取其 f[7](深市全市成交额,万元) +_TENCENT_SZ_TOTAL = "s_sz399106" +_TENCENT_MAP = {ts_code: sym for ts_code, _, _, sym in MARKET_INDEXES} +_TENCENT_URL = "http://qt.gtimg.cn/q=" + ",".join([*_TENCENT_MAP.values(), _TENCENT_SZ_TOTAL]) + _SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘 _HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark) _CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频 -_CACHE_KEY = "market_overview:v1" +_EOD_KEY = "market_overview:eod:v2" # v2:与旧整包缓存 v1 的 payload 形状不同,天然隔离 +_AMOUNT_HIST_CAL_DAYS = 190 # 成交额历史的日历日窗口(≈128 交易日) +_AMOUNT_HIST_BARS = 120 # 输出的柱数(取尾部) +_LIVE_FAIL_TTL = 15.0 # live 层失败负缓存(秒) class MarketOverviewError(RuntimeError): @@ -66,6 +84,74 @@ def _get_pro(): return get_pro() +# ======================= live 层:腾讯实时行情 ======================= + +_live: dict = {"at": 0.0, "quotes": None} # 进程内缓存(monotonic 时钟) +_live_error: str | None = None # 最近一次实时拉取失败的原因(成功后清空) + + +async def _fetch_live_http() -> dict[str, dict]: + """一次 GET 拿全部符号。响应 GBK,行如 v_s_sh000001="1~上证指数~000001~3930.12~-11.97~-0.30~537286161~93825519~~"; + + 字段序:f[1]名称 / f[3]现价 / f[4]涨跌 / f[5]涨跌% / f[7]成交额。 + 单位陷阱:仅 s_sh000001 与 s_sz399106 的 f[7] 是「万元、全市口径」,可算两市成交额; + 港股行的 f[7] 是手数、美股行非人民币金额,s_sz399001(深证成指)是成分股口径——都不能用。 + """ + async with httpx.AsyncClient(timeout=settings.tencent_quote_timeout) as client: + resp = await client.get(_TENCENT_URL) + resp.raise_for_status() + text = resp.content.decode("gbk", errors="replace") # 响应头 charset 不可靠,显式解码 + + quotes: dict[str, dict] = {} + for line in text.splitlines(): + if "=" not in line: + continue + head, _, body = line.partition("=") + sym = head.strip().removeprefix("v_") + fields = body.strip().strip(';"').split("~") + if not sym or len(fields) < 8: + continue + + def _num(i: int) -> float | None: + try: + return float(fields[i]) + except (TypeError, ValueError): + return None + + quotes[sym] = { + "name": fields[1], + "price": _num(3), + "change": _num(4), + "pct": _num(5), + "amount_wan": _num(7), + } + if not quotes: + raise RuntimeError("腾讯行情响应为空或无法解析") + return quotes + + +async def _fetch_live() -> dict[str, dict] | None: + """实时行情(进程内缓存);任何失败返回 None,上层降级 EOD。""" + global _live_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 # 负缓存:刚失败过,短时间内不再打腾讯 + try: + quotes = await _fetch_live_http() + except Exception as e: # noqa: BLE001 —— 实时层是锦上添花,失败不拖垮整包 + _live.update(at=now, quotes=None) + _live_error = f"实时行情: {str(e)[:60]}" + return None + _live.update(at=now, quotes=quotes) + _live_error = None + return quotes + + +# ======================= EOD 层:tushare 收盘数据 ======================= + def _fetch_index_sync(pro, ts_code: str) -> pd.DataFrame: start = (datetime.now() - timedelta(days=_HISTORY_DAYS)).strftime("%Y%m%d") if "." in ts_code: # A 股指数(000001.SH 形式) @@ -91,7 +177,10 @@ def _quote_from_df(df: pd.DataFrame) -> dict | None: def _fetch_stats_sync(pro) -> dict | None: - """两市市值/成交统计:沪 SH_A + 深 SZ_MARKET(同一天口径相加,亿元)。""" + """两市市值/成交统计:沪 SH_MARKET(主板A+科创+B,不含基金)+ 深 SZ_MARKET(全部股票)。 + + 口径与本地 candles 全市场 sum(amount) 吻合(candles 另含北交所,约 +70 亿)。 + """ start = (datetime.now() - timedelta(days=14)).strftime("%Y%m%d") sh = pro.daily_info(exchange="SH", start_date=start) sz = pro.daily_info(exchange="SZ", start_date=start) @@ -102,14 +191,14 @@ def _fetch_stats_sync(pro) -> dict | None: sub = df[df["ts_code"] == code].sort_values("trade_date") # 接口不保证有序 return sub.iloc[-1] if not sub.empty else None - sh_a, sz_m = _board(sh, "SH_A"), _board(sz, "SZ_MARKET") - if sh_a is None or sz_m is None: + sh_m, sz_m = _board(sh, "SH_MARKET"), _board(sz, "SZ_MARKET") + if sh_m is None or sz_m is None: return None # 两边各自取最新,日期不一致时以较旧一天为准凑齐口径(罕见,通常同日) - d = min(_d(sh_a["trade_date"]), _d(sz_m["trade_date"])) + d = min(_d(sh_m["trade_date"]), _d(sz_m["trade_date"])) def _sum(col: str) -> float | None: - a, b = _f(sh_a.get(col)), _f(sz_m.get(col)) + a, b = _f(sh_m.get(col)), _f(sz_m.get(col)) return None if a is None or b is None else round(a + b, 2) return { @@ -117,21 +206,42 @@ def _fetch_stats_sync(pro) -> dict | None: "total_mv": _sum("total_mv"), "float_mv": _sum("float_mv"), "amount": _sum("amount"), - "turnover": _f(sh_a.get("tr")), # 换手率仅沪市有,展示口径注明沪市 + "turnover": _f(sh_m.get("tr")), # 换手率仅沪市有,展示口径注明沪市 } -async def fetch_overview() -> dict: - """聚合全部指数 + 两市统计(Redis 缓存整包,TTL 内直接回)。""" - cached = await cache.cache_get(_CACHE_KEY) - if cached is not None: - return cached +def _fetch_amount_history_sync(pro) -> list[dict]: + """两市成交额历史:daily_info 范围查询一次拉多日(SH 6 个月实测 0.09s), + 沪 SH_MARKET + 深 SZ_MARKET 按日对齐相加(接口原生亿元),升序取尾部 120 根。""" + start = (datetime.now() - timedelta(days=_AMOUNT_HIST_CAL_DAYS)).strftime("%Y%m%d") + sh = pro.daily_info(exchange="SH", start_date=start) + sz = pro.daily_info(exchange="SZ", start_date=start) + if sh is None or sh.empty or sz is None or sz.empty: + return [] + sh_m = sh[sh["ts_code"] == "SH_MARKET"].set_index("trade_date")["amount"] + sz_m = sz[sz["ts_code"] == "SZ_MARKET"].set_index("trade_date")["amount"] + common = sh_m.index.intersection(sz_m.index) # 内连接:两市都有数据的交易日 + if len(common) == 0: + return [] + total = (sh_m[common] + sz_m[common]).sort_index() + return [{"date": _d(d), "amount": round(float(v), 2)} for d, v in total.tail(_AMOUNT_HIST_BARS).items()] + +# ---- EOD 的 SWR(stale-while-revalidate):新鲜期内直返;过期先返旧值后台刷新 ---- + +_eod_state: dict = {"payload": None} # 进程内新鲜/陈旧兜底(payload 自带 fetched_ts 墙钟) +_eod_refreshing = False # 后台刷新防重入标志 +_eod_refresh_error: str | None = None # 最近一次后台刷新失败的原因 +_bg_tasks: set[asyncio.Task] = set() # 持引用防 GC + + +async def _refresh_eod() -> dict: + """拉全量 EOD(9 指数 + 统计 + 成交额历史,顺序控频),写进程内 state + Redis。""" pro = await asyncio.to_thread(_get_pro) indexes: list[dict] = [] errors: list[str] = [] - for ts_code, name, region in MARKET_INDEXES: + for ts_code, name, region, _sym in MARKET_INDEXES: try: df = await asyncio.to_thread(_fetch_index_sync, pro, ts_code) q = _quote_from_df(df) @@ -152,11 +262,112 @@ async def fetch_overview() -> dict: except Exception as e: # noqa: BLE001 —— 统计缺失时指数照常展示 errors.append(f"两市统计: {str(e)[:60]}") + amount_history: list[dict] = [] + try: + await asyncio.sleep(_CALL_INTERVAL) + amount_history = await asyncio.to_thread(_fetch_amount_history_sync, pro) + except Exception as e: # noqa: BLE001 —— 历史图缺数据时其余照常 + errors.append(f"成交额历史: {str(e)[:60]}") + payload = { - "updated_at": datetime.now().isoformat(), # 字符串:cache_set 走 json.dumps + "fetched_at": datetime.now().isoformat(), + "fetched_ts": time.time(), # 墙钟(epoch float):跨进程(Redis)判新鲜度用 "indexes": indexes, "stats": stats, + "amount_history": amount_history, "errors": errors, } - await cache.cache_set(_CACHE_KEY, payload, ttl=settings.market_overview_ttl) + _eod_state["payload"] = payload + await cache.cache_set(_EOD_KEY, payload, ttl=settings.market_eod_redis_ttl) return payload + + +async def _refresh_eod_wrapped() -> None: + """后台刷新的主体:失败静默保留旧值并记录原因(下次请求并入 errors 便于排查)。""" + global _eod_refresh_error, _eod_refreshing + try: + await _refresh_eod() + _eod_refresh_error = None + except Exception as e: # noqa: BLE001 + _eod_refresh_error = f"EOD后台刷新: {str(e)[:60]}" + finally: + _eod_refreshing = False + + +def _spawn_eod_refresh() -> None: + global _eod_refreshing + if _eod_refreshing: + return + _eod_refreshing = True + task = asyncio.create_task(_refresh_eod_wrapped()) + _bg_tasks.add(task) + task.add_done_callback(_bg_tasks.discard) + + +async def _get_eod() -> dict: + """读 EOD:内存新鲜直返(0 RTT)→ Redis 回填 → 有旧值先返 + SWR 后台刷新 → 真冷启动同步拉。""" + p = _eod_state["payload"] + if p is not None and time.time() - p["fetched_ts"] < settings.market_eod_fresh_ttl: + return p + if p is None: + cached = await cache.cache_get(_EOD_KEY) + if cached: + p = cached + _eod_state["payload"] = p + if p is not None: + _spawn_eod_refresh() # 陈旧但可用:立即返回,后台拉新 + return p + return await _refresh_eod() # 首次访问:同步等(~3.5s,与旧行为一致) + + +# ======================= merge:实时叠加收盘 ======================= + +async def fetch_overview(is_trading_day: bool | None = None) -> dict: + """聚合 live + EOD。实时价覆盖 close/change/pct_chg(realtime=True),今日实时成交额 + (腾讯全市口径)在 EOD 尚未含今日时追加为盘中 bar。响应不再整包缓存:两层各有 + 进程内缓存,合并是 O(10) 操作,热路径 0 外部 RTT。""" + eod = await _get_eod() + live = await _fetch_live() + today_iso = date.today().isoformat() + + indexes: list[dict] = [] + for it in eod["indexes"]: + out = dict(it) + sym = _TENCENT_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(末点是上一收盘点,与实时价并存是已知的装饰性差异,不改历史序列) + out.update(close=q["price"], change=q["change"], pct_chg=q["pct"], + trade_date=today_iso, realtime=True) + else: + out["realtime"] = False + indexes.append(out) + + stats = dict(eod["stats"]) if eod.get("stats") else None + history = list(eod.get("amount_history") or []) + + # 今日实时两市成交额:沪深全市口径(万元->亿)。EOD 已含今日、非交易日、金额缺失时不追加。 + if live and stats and stats.get("trade_date") != today_iso: + if is_trading_day is None: + is_trading_day = datetime.now().weekday() < 5 # 日历判定不可用时的降级启发式 + if is_trading_day: + sh_amt = (live.get("s_sh000001") or {}).get("amount_wan") + sz_amt = (live.get(_TENCENT_SZ_TOTAL) or {}).get("amount_wan") + if sh_amt is not None and sz_amt is not None: + amt = round((sh_amt + sz_amt) / 10000, 2) + stats["amount_today"] = amt + history.append({"date": today_iso, "amount": amt, "intraday": True}) + + errors = list(eod.get("errors") or []) + if _eod_refresh_error: + errors.append(_eod_refresh_error) + if _live_error: + errors.append(_live_error) + + return { + "updated_at": datetime.now().isoformat(), + "indexes": indexes, + "stats": stats, + "amount_history": history, + "errors": errors, + } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 17ee1ba..0f4303d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -383,24 +383,34 @@ class IndexQuoteOut(BaseModel): close: float | None = None change: float | None = None pct_chg: float | None = None - trade_date: date | None = None # 最近交易日(收盘口径) + trade_date: date | None = None # 行情归属交易日(实时时为当天) spark: list[float] = [] # 近 N 日收盘(旧 -> 新),迷你走势用 spark_dates: list[str] = [] # 与 spark 对齐的交易日(YYYYMMDD),hover 提示用 + realtime: bool = False # True=盘中实时(腾讯),False=最近收盘(tushare EOD) class MarketStatsOut(BaseModel): - """沪深两市汇总(daily_info:沪 SH_A + 深 SZ_MARKET,亿元)。""" + """沪深两市汇总(daily_info:沪 SH_MARKET(主板A+科创+B) + 深 SZ_MARKET,不含基金/北交所,亿元)。""" trade_date: date | None = None total_mv: float | None = None # 总市值(亿元) float_mv: float | None = None # 流通市值(亿元) - amount: float | None = None # 两市成交额(亿元) + amount: float | None = None # 两市成交额(亿元,EOD) turnover: float | None = None # 换手率 %(沪市口径) + amount_today: float | None = None # 当日实时两市成交额(亿元,腾讯;收盘后为当日终值) + + +class AmountBarOut(BaseModel): + """两市成交额历史的一根柱(亿元)。""" + date: date + amount: float + intraday: bool = False # True=当日实时口径(尚未并入 EOD) class MarketOverviewResponse(BaseModel): updated_at: datetime indexes: list[IndexQuoteOut] = [] stats: MarketStatsOut | None = None + amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中) errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示) diff --git a/backend/app/screener/market_sync.py b/backend/app/screener/market_sync.py index cafade9..847cdae 100644 --- a/backend/app/screener/market_sync.py +++ b/backend/app/screener/market_sync.py @@ -339,6 +339,13 @@ async def _run_sync(days: int, force: bool) -> None: # candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效) await cache.bump_version("candles") + # 预热同步状态缓存:同步任务自己付一次重聚合(>10s),轮询方毫秒级拿到新数字 + _sync_state["step"] = "正在更新统计缓存" + try: + async with async_session() as s2: + await _db_stats(s2) + except Exception: # noqa: BLE001 —— 预热失败只影响下一次轮询的时延 + pass _sync_state["step"] = "同步完成" except Exception as e: # noqa: BLE001 _sync_state["error"] = f"同步失败:{str(e)[:300]}" @@ -362,31 +369,56 @@ async def start_sync(session: AsyncSession, days: int, force: bool) -> dict: return dict(_sync_state) -# candles 是千万行表,count 较重;前端每 2s 轮询状态,需 TTL 缓存降载 -_status_stats_cache: dict = {"at": 0.0, "data": None} -_STATS_TTL = 30.0 +# candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s; +# 结果按 ver:candles 版本号缓存(同步完成即 bump 失效),前端 2s 轮询只付毫秒级。 +_status_stats_cache: dict = {"at": 0.0, "ver": -1, "data": None} +_STATS_TTL = 120.0 # 进程内兜底 TTL(Redis 不可用时重聚合的最小间隔) +_stats_bg_tasks: set[asyncio.Task] = set() # 后台写缓存的引用,防 GC +_stats_lock = asyncio.Lock() # 单飞锁:同步尾部的预热与轮询并发时,重聚合只跑一次 -async def _db_stats(session: AsyncSession) -> dict: - """candles/快照/股票列表实况(30s TTL 缓存)。""" - now = time.time() - if _status_stats_cache["data"] is not None and now - _status_stats_cache["at"] < _STATS_TTL: - return _status_stats_cache["data"] +async def _heavy_stats(session: AsyncSession) -> dict: + """重聚合:行数/日期数。4 条查询走千万行表(>11s),绝不能落在轮询热路径上。""" stocks = int(await session.scalar(select(func.count()).select_from(StockBasic)) or 0) daily_rows = int(await session.scalar(select(func.count()).select_from(Candle)) or 0) snap_rows = int(await session.scalar(select(func.count()).select_from(DailySnapshot)) or 0) - last_daily = await session.scalar( - select(func.max(Candle.ts)).where(Candle.timeframe == "1d") - ) n_dates = int(await session.scalar( select(func.count(func.distinct(func.date(Candle.ts)))).where(Candle.timeframe == "1d") ) or 0) - data = { - "stocks": stocks, "daily_rows": daily_rows, "snapshot_rows": snap_rows, - "last_daily": last_daily, "dates": n_dates, + return { + "stocks": stocks, "daily_rows": daily_rows, + "snapshot_rows": snap_rows, "dates": n_dates, } - _status_stats_cache.update(at=now, data=data) - return data + + +async def _db_stats(session: AsyncSession) -> dict: + """candles/快照/股票列表实况:重聚合走「版本化 Redis + 进程内」双层缓存, + last_daily(max(ts),走索引很快)保持每次实时——它是 UI 主展示字段。""" + + def _fresh_local(ver: int) -> dict | None: + d = _status_stats_cache["data"] + if d is not None and _status_stats_cache["ver"] == ver \ + and time.time() - _status_stats_cache["at"] < _STATS_TTL: + return d + return None + + ver = await cache.get_version("candles") + heavy = _fresh_local(ver) + if heavy is None: + async with _stats_lock: # 双检:等锁期间可能已被并发请求/同步预热填充 + heavy = _fresh_local(ver) or await cache.cache_get(f"syncstats:v{ver}") + if heavy is None: + heavy = await _heavy_stats(session) + # 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢本次返回 + task = asyncio.create_task( + cache.cache_set(f"syncstats:v{ver}", heavy, ttl=settings.sync_stats_redis_ttl)) + _stats_bg_tasks.add(task) + task.add_done_callback(_stats_bg_tasks.discard) + _status_stats_cache.update(at=time.time(), ver=ver, data=heavy) + last_daily = await session.scalar( + select(func.max(Candle.ts)).where(Candle.timeframe == "1d") + ) + return {**heavy, "last_daily": last_daily} async def get_sync_status(session: AsyncSession) -> dict: diff --git a/backend_run.log b/backend_run.log index 450e4a0..f5d4fe0 100644 --- a/backend_run.log +++ b/backend_run.log @@ -1,103 +1,70 @@ -INFO: Started server process [42884] +INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [2136] using WatchFiles +INFO: Started server process [3296] INFO: Waiting for application startup. INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:60648 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:62525 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:62531 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:62573 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK -INFO: 127.0.0.1:62746 - "POST /api/screener/run HTTP/1.1" 200 OK -INFO: 127.0.0.1:62747 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64917 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:64915 - "GET /api/trades?ts_code=601398.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64916 - "GET /api/screener/preview/601398.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64933 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64942 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64947 - "GET /api/trades?ts_code=601939.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64949 - "GET /api/screener/preview/601939.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64958 - "GET /api/trades?ts_code=601288.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64954 - "GET /api/screener/preview/601939.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64964 - "GET /api/screener/preview/601288.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64965 - "GET /api/screener/preview/601288.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64970 - "GET /api/trades?ts_code=600941.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64971 - "GET /api/screener/preview/600941.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64982 - "GET /api/trades?ts_code=601988.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64978 - "GET /api/screener/preview/600941.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64983 - "GET /api/screener/preview/601988.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64987 - "GET /api/screener/preview/601988.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64992 - "GET /api/trades?ts_code=601857.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64993 - "GET /api/screener/preview/601857.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65001 - "GET /api/trades?ts_code=600938.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64997 - "GET /api/screener/preview/601857.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65004 - "GET /api/screener/preview/600938.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65008 - "GET /api/screener/preview/600938.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65014 - "GET /api/trades?ts_code=601628.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65016 - "GET /api/screener/preview/601628.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65023 - "GET /api/screener/preview/601628.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65025 - "GET /api/trades?ts_code=601088.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65028 - "GET /api/screener/preview/601088.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65029 - "GET /api/screener/preview/601088.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-29 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65037 - "GET /api/trades?ts_code=600036.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65038 - "GET /api/screener/preview/600036.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65040 - "GET /api/screener/preview/600036.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65046 - "GET /api/trades?ts_code=601318.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65047 - "GET /api/screener/preview/601318.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65057 - "GET /api/screener/preview/601318.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65060 - "GET /api/trades?ts_code=600900.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65063 - "GET /api/screener/preview/600900.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65064 - "GET /api/screener/preview/600900.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65075 - "GET /api/trades?ts_code=688256.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65076 - "GET /api/screener/preview/688256.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65078 - "GET /api/screener/preview/688256.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65085 - "GET /api/trades?ts_code=601658.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65086 - "GET /api/screener/preview/601658.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65090 - "GET /api/screener/preview/601658.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65100 - "GET /api/trades?ts_code=000333.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:65101 - "GET /api/screener/preview/000333.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65104 - "GET /api/screener/preview/000333.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65108 - "GET /api/trades?ts_code=600028.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65110 - "GET /api/screener/preview/600028.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65114 - "GET /api/screener/preview/600028.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65123 - "GET /api/trades?ts_code=601328.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65125 - "GET /api/screener/preview/601328.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65140 - "GET /api/screener/preview/601328.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65144 - "GET /api/trades?ts_code=601728.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65148 - "GET /api/screener/preview/601728.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65149 - "GET /api/screener/preview/601728.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65154 - "GET /api/trades?ts_code=601998.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65155 - "GET /api/screener/preview/601998.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65160 - "GET /api/screener/preview/601998.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65164 - "GET /api/trades?ts_code=002475.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:65168 - "GET /api/screener/preview/002475.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65169 - "GET /api/screener/preview/002475.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65176 - "GET /api/trades?ts_code=600183.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65177 - "GET /api/screener/preview/600183.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65180 - "GET /api/screener/preview/600183.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65187 - "GET /api/trades?ts_code=601319.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65188 - "GET /api/screener/preview/601319.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65190 - "GET /api/screener/preview/601319.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65196 - "GET /api/trades?ts_code=601869.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65197 - "GET /api/screener/preview/601869.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65201 - "GET /api/screener/preview/601869.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65207 - "GET /api/trades?ts_code=601601.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65208 - "GET /api/screener/preview/601601.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65212 - "GET /api/screener/preview/601601.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65217 - "GET /api/trades?ts_code=600000.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65220 - "GET /api/screener/preview/600000.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65221 - "GET /api/screener/preview/600000.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65226 - "GET /api/trades?ts_code=601225.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65227 - "GET /api/screener/preview/601225.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65232 - "GET /api/screener/preview/601225.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65237 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65239 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65246 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:65247 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65249 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65256 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65255 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65278 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65277 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:65282 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK -INFO: 127.0.0.1:65285 - "GET /api/trades?ts_code=600309.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:65288 - "GET /api/screener/preview/600309.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:65292 - "GET /api/screener/preview/600309.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK +INFO: 127.0.0.1:48937 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:49077 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:49092 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:49093 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:49094 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +WARNING: WatchFiles detected changes in 'scripts\diag_market_sources.py'. Reloading... + INFO: 127.0.0.1:52219 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:52237 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:52238 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:52239 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52861 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:52871 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:52876 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:52883 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53667 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53671 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:53670 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53678 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53710 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:53753 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:54745 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:54746 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 127.0.0.1:59489 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:59506 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:59507 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:59508 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59720 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:59721 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:59727 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:59739 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:59740 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:59748 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:59753 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:59756 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:59754 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:59771 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:59778 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:59777 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:59730 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59749 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59760 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59779 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:13210 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:16263 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:16275 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:16276 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16303 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:16309 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16308 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:16319 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:16322 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16320 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:16349 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:16357 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16352 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:16310 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:16277 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:16325 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:16358 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:16598 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 127.0.0.1:16599 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16600 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16601 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:16602 - "GET /api/market/overview HTTP/1.1" 200 OK diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 307985d..97ccd24 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -388,22 +388,31 @@ export interface IndexQuote { close: number | null; change: number | null; pct_chg: number | null; - trade_date: string | null; // 最近交易日(ISO) + trade_date: string | null; // 行情归属交易日(ISO;实时时为当天) spark: number[]; // 近 N 日收盘(旧 -> 新) spark_dates: string[]; // 与 spark 对齐的交易日(YYYYMMDD) + realtime?: boolean; // true=盘中实时(腾讯),false=最近收盘(EOD) } export interface MarketStats { trade_date: string | null; total_mv: number | null; // 沪深总市值(亿元) float_mv: number | null; // 沪深流通市值(亿元) - amount: number | null; // 两市成交额(亿元) + amount: number | null; // 两市成交额(亿元,EOD) turnover: number | null; // 换手率 %(沪市口径) + amount_today?: number | null; // 当日实时两市成交额(亿元;收盘后为当日终值) +} + +export interface AmountBar { + date: string; // ISO 日期 + amount: number; // 两市成交额(亿元) + intraday?: boolean; // true=当日实时口径(尚未并入 EOD) } export interface MarketOverview { updated_at: string; indexes: IndexQuote[]; stats: MarketStats | null; + amount_history: AmountBar[]; // 近 N 交易日两市成交额(旧 -> 新,末根可能盘中) errors: string[]; } diff --git a/frontend/src/components/MarketOverview.vue b/frontend/src/components/MarketOverview.vue index cedc15f..bc53036 100644 --- a/frontend/src/components/MarketOverview.vue +++ b/frontend/src/components/MarketOverview.vue @@ -4,6 +4,8 @@ import { computed, onMounted, reactive, ref } from 'vue'; import { getMarketOverview } from '@/api/client'; import type { IndexQuote, MarketOverview as Overview } from '@/api/types'; +import AmountHistoryChart from '@/components/AmountHistoryChart.vue'; + // 主页大盘总览:A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。 // 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。 @@ -119,7 +121,7 @@ function hoverText(it: IndexQuote): string {

大盘行情 - 收盘口径 · 每晚更新 + 实时行情 · 收盘走势

更新于 {{ updatedAt }} @@ -167,7 +169,11 @@ function hoverText(it: IndexQuote): string { >
{{ it.name }} - {{ fmtDate(it.trade_date) }} + + + 实时 + + {{ fmtDate(it.trade_date) }}
{{ fmtClose(it.close) }} @@ -220,14 +226,19 @@ function hoverText(it: IndexQuote): string {
- -
+ +
沪深两市 {{ fmtDate(overview.stats.trade_date) }} 成交额 - {{ fmtYi(overview.stats.amount) }} + {{ fmtYi(overview.stats.amount_today ?? overview.stats.amount) }} + 今日 总市值 @@ -243,6 +254,13 @@ function hoverText(it: IndexQuote): string {
+ + +
{{ overview.errors.length }} 项数据获取失败,已跳过 diff --git a/frontend_dev.log b/frontend_dev.log index 472b83c..4dcdbd2 100644 --- a/frontend_dev.log +++ b/frontend_dev.log @@ -1,31 +1,6 @@ $ vite - VITE v6.4.3 ready in 717 ms + VITE v6.4.3 ready in 1900 ms ➜ Local: http://localhost:5173/  ➜ Network: use --host to expose -10:03:08 [vite] (client) page reload src/api/types.ts -10:03:24 [vite] (client) page reload src/api/client.ts -10:03:38 [vite] (client) page reload src/api/client.ts -10:03:48 [vite] (client) hmr update /src/style.css, /src/views/ScreenerView.vue -10:04:11 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css -10:04:24 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css -10:04:40 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css -10:15:13 [vite] (client) page reload src/api/client.ts -10:15:27 [vite] (client) hmr update /src/style.css, /src/views/ScreenerView.vue -10:15:48 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css -10:15:53 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css -10:16:01 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css -10:16:06 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css -10:16:11 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css -10:20:01 [vite] http proxy error: /api/screener/run -Error: read ECONNRESET - at TCP.onStreamRead (node:internal/stream_base_commons:216:20) -10:23:16 [vite] http proxy error: /api/auth/me -AggregateError [ECONNREFUSED]: - at internalConnectMultiple (node:net:1134:18) - at afterConnectMultiple (node:net:1715:7) -10:23:20 [vite] http proxy error: /api/auth/login -AggregateError [ECONNREFUSED]: - at internalConnectMultiple (node:net:1134:18) - at afterConnectMultiple (node:net:1715:7)