This commit is contained in:
2026-09-04 19:05:23 +08:00
parent 76b422320b
commit 991d1bf343
10 changed files with 449 additions and 195 deletions

View File

@@ -14,13 +14,15 @@
```bash ```bash
# 终端 1 —— 后端 APIhttp://localhost:8000 # 终端 1 —— 后端 APIhttp://localhost:8000
cd backend 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 # 终端 2 —— 前端开发服务器http://localhost:5173
cd frontend cd frontend
pnpm dev pnpm dev
``` ```
> `env -u SSLKEYLOGFILE` 是本机Windows必需的用户环境变量 `SSLKEYLOGFILE` 值开头混有不可见控制符asyncpg 建连即崩。详见下文「快速开始(开发模式)」与「常见问题」。
首次运行前先安装依赖:后端 `uv sync`backend 下)、前端 `pnpm install`frontend 下)。 首次运行前先安装依赖:后端 `uv sync`backend 下)、前端 `pnpm install`frontend 下)。
浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。 浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。
@@ -115,12 +117,25 @@ pip install uv
### 1) 后端 ### 1) 后端
实际启动命令venv 解释器直启,日志重定向到仓库根 `backend_run.log`,改代码自动热重载):
```bash ```bash
cd backend cd backend
uv sync # 创建 .venv 并安装依赖(首次) uv sync # 创建 .venv 并安装依赖(首次)
uv run uvicorn app.main:app --reload --port 8000 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 <pid> //T //F`
- 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。 - 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。
- 交互式 API 文档http://localhost:8000/docs - 交互式 API 文档http://localhost:8000/docs
@@ -306,6 +321,8 @@ volumes: { pgdata: {} }
- **真实标的首次回测慢/拉取失败**:首次需联网请求 Tushare数秒。Tushare 对 `adj_factor` 限频(每小时 1 次),触发时自动退化为不复权日线,不影响回测。数据已本地缓存,二次回测秒出。若 Tushare 积分不足,`uv add akshare` 后将自动用 AKShare 兜底。 - **真实标的首次回测慢/拉取失败**:首次需联网请求 Tushare数秒。Tushare 对 `adj_factor` 限频(每小时 1 次),触发时自动退化为不复权日线,不影响回测。数据已本地缓存,二次回测秒出。若 Tushare 积分不足,`uv add akshare` 后将自动用 AKShare 兜底。
- **高价股(如茅台)回测 0 信号**:一手太贵买不起。默认初始资金已设为 100 万;如仍不够,在表单调高「初始资金」。 - **高价股(如茅台)回测 0 信号**:一手太贵买不起。默认初始资金已设为 100 万;如仍不够,在表单调高「初始资金」。
- **Python 3.14 兼容**MVP 依赖fastapi/sqlalchemy/pandas/numpy均已支持指标用纯 Python 实现,不依赖 TA-Lib 的 C 库Windows 免编译。 - **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 <pid> //T //F` 杀进程树重启。
--- ---

View File

@@ -44,6 +44,7 @@ from .models import (
Candle, Candle,
ScreenerQuery, ScreenerQuery,
StockBasic, StockBasic,
TradeCalendar,
UserPreference, UserPreference,
UserTrade, UserTrade,
WatchlistItem, WatchlistItem,
@@ -387,13 +388,22 @@ async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response
@router.get("/market/overview", response_model=MarketOverviewResponse) @router.get("/market/overview", response_model=MarketOverviewResponse)
async def get_market_overview() -> MarketOverviewResponse: async def get_market_overview(session: AsyncSession = Depends(get_session)) -> MarketOverviewResponse:
"""主页大盘总览A 股 + 港美指数最近收盘(含迷你走势),沪深两市市值/成交统计。 """主页大盘总览A 股 + 港美指数实时价腾讯叠加收盘历史走势tushare
沪深两市市值/成交统计 + 成交额历史。部分来源失败不影响其余。
收盘口径token 无实时权限),展示时标注交易日;部分来源失败不影响其余。
""" """
# 今日是否 A 股交易日trade_date 为 String(8) unique 索引,等值查亚毫秒级);
# DB 不可用时传 Nonefetch_overview 回退 weekday 启发式
today8 = datetime.now().strftime("%Y%m%d")
is_trading_day: bool | None = None
try: 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: except MarketOverviewError as e:
raise HTTPException(status_code=503, detail=str(e)) from e raise HTTPException(status_code=503, detail=str(e)) from e
return MarketOverviewResponse(**data) return MarketOverviewResponse(**data)

View File

@@ -38,7 +38,12 @@ class Settings(BaseSettings):
redis_url: str = "" redis_url: str = ""
stocks_cache_ttl: int = 300 # 股票列表缓存秒数(行情列允许最多滞后这么多秒) stocks_cache_ttl: int = 300 # 股票列表缓存秒数(行情列允许最多滞后这么多秒)
facets_cache_ttl: int = 3600 # 行业/地域筛选项缓存秒数stock_basic 很少变) 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 TTLSWR 陈旧值的兜底存活期)
tencent_quote_timeout: float = 3.0 # 腾讯行情 HTTP 超时(秒)
sync_stats_redis_ttl: int = 43200 # 同步状态重聚合的 Redis 缓存秒数key 带版本号,同步完成即失效)
# ---- LLM智能选股的自然语言解析DeepSeekOpenAI 兼容协议,可换任意兼容网关)---- # ---- LLM智能选股的自然语言解析DeepSeekOpenAI 兼容协议,可换任意兼容网关)----
llm_base_url: str = "https://api.deepseek.com" llm_base_url: str = "https://api.deepseek.com"

View File

@@ -1,40 +1,58 @@
"""大盘行情总览(主页展示)。 """大盘行情总览(主页展示)——两层结构
- A 股指数走 pro.index_daily全球指数走 pro.index_global均为收盘口径晚间更新 - live 层腾讯免费实时行情qt.gtimg.cn一次 GET 拿全部指数现价/涨跌幅),
本 token 无 rt_idx_k 实时权限,故展示「最近交易日收盘」并标注日期)。 进程内缓存 30s成功/ 15s 负缓存(失败,防止接口抖动持续拖慢请求)。
- 两市统计走 pro.daily_info沪市取 SH_A、深市取 SZ_MARKET 汇总出 - EOD 层tushare 收盘数据 —— A 股指数 pro.index_daily、全球指数 pro.index_global
总市值 / 流通市值 / 成交额(单位亿元,接口原生口径)。 45 日 spark 走势)+ 两市统计/成交额历史 pro.daily_info。收盘数据一天一变
- 整包结果写 Redis 缓存TTL 可配单个指数拉取失败只是跳过errors 里注明), 进程内新鲜期 4h + Redis 兜底 24h过期走 SWR先返旧值后台刷新永不阻塞用户
全部失败才抛 MarketOverviewError —— 主页行情是锦上添花,不拖垮整页。 - merge实时价覆盖 close/change/pct_chgrealtime=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 from __future__ import annotations
import asyncio import asyncio
import math import math
import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import httpx
import pandas as pd import pandas as pd
from .. import cache from .. import cache
from ..config import settings from ..config import settings
# (代码, 名称, 地区) —— 展示顺序即列表顺序 # (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序
MARKET_INDEXES: list[tuple[str, str, str]] = [ # 标普500 腾讯符号是 s_usINX不是 s_usSPX恒生科技是 s_hkHSTECH不是 HSTECH
("000001.SH", "上证指数", "cn"), MARKET_INDEXES: list[tuple[str, str, str, str]] = [
("399001.SZ", "深证成指", "cn"), ("000001.SH", "上证指数", "cn", "s_sh000001"),
("399006.SZ", "创业板", "cn"), ("399001.SZ", "深证成", "cn", "s_sz399001"),
("000688.SH", "科创50", "cn"), ("399006.SZ", "创业板指", "cn", "s_sz399006"),
("HSI", "恒生指数", "hk"), ("000688.SH", "科创50", "cn", "s_sh000688"),
("HKTECH", "恒生科技", "hk"), ("HSI", "恒生指数", "hk", "s_hkHSI"),
("DJI", "道琼斯", "us"), ("HKTECH", "恒生科技", "hk", "s_hkHSTECH"),
("IXIC", "纳斯达克", "us"), ("DJI", "道琼斯", "us", "s_usDJI"),
("SPX", "标普500", "us"), ("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 个交易日收盘 _SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark _HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频 _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): class MarketOverviewError(RuntimeError):
@@ -66,6 +84,74 @@ def _get_pro():
return 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: def _fetch_index_sync(pro, ts_code: str) -> pd.DataFrame:
start = (datetime.now() - timedelta(days=_HISTORY_DAYS)).strftime("%Y%m%d") start = (datetime.now() - timedelta(days=_HISTORY_DAYS)).strftime("%Y%m%d")
if "." in ts_code: # A 股指数000001.SH 形式) 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: 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") start = (datetime.now() - timedelta(days=14)).strftime("%Y%m%d")
sh = pro.daily_info(exchange="SH", start_date=start) sh = pro.daily_info(exchange="SH", start_date=start)
sz = pro.daily_info(exchange="SZ", 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") # 接口不保证有序 sub = df[df["ts_code"] == code].sort_values("trade_date") # 接口不保证有序
return sub.iloc[-1] if not sub.empty else None return sub.iloc[-1] if not sub.empty else None
sh_a, sz_m = _board(sh, "SH_A"), _board(sz, "SZ_MARKET") sh_m, sz_m = _board(sh, "SH_MARKET"), _board(sz, "SZ_MARKET")
if sh_a is None or sz_m is None: if sh_m is None or sz_m is None:
return 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: 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 None if a is None or b is None else round(a + b, 2)
return { return {
@@ -117,21 +206,42 @@ def _fetch_stats_sync(pro) -> dict | None:
"total_mv": _sum("total_mv"), "total_mv": _sum("total_mv"),
"float_mv": _sum("float_mv"), "float_mv": _sum("float_mv"),
"amount": _sum("amount"), "amount": _sum("amount"),
"turnover": _f(sh_a.get("tr")), # 换手率仅沪市有,展示口径注明沪市 "turnover": _f(sh_m.get("tr")), # 换手率仅沪市有,展示口径注明沪市
} }
async def fetch_overview() -> dict: def _fetch_amount_history_sync(pro) -> list[dict]:
"""聚合全部指数 + 两市统计Redis 缓存整包TTL 内直接回)。""" """两市成交额历史daily_info 范围查询一次拉多日SH 6 个月实测 0.09s
cached = await cache.cache_get(_CACHE_KEY) 沪 SH_MARKET + 深 SZ_MARKET 按日对齐相加(接口原生亿元),升序取尾部 120 根。"""
if cached is not None: start = (datetime.now() - timedelta(days=_AMOUNT_HIST_CAL_DAYS)).strftime("%Y%m%d")
return cached 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 的 SWRstale-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:
"""拉全量 EOD9 指数 + 统计 + 成交额历史,顺序控频),写进程内 state + Redis。"""
pro = await asyncio.to_thread(_get_pro) pro = await asyncio.to_thread(_get_pro)
indexes: list[dict] = [] indexes: list[dict] = []
errors: list[str] = [] errors: list[str] = []
for ts_code, name, region in MARKET_INDEXES: for ts_code, name, region, _sym in MARKET_INDEXES:
try: try:
df = await asyncio.to_thread(_fetch_index_sync, pro, ts_code) df = await asyncio.to_thread(_fetch_index_sync, pro, ts_code)
q = _quote_from_df(df) q = _quote_from_df(df)
@@ -152,11 +262,112 @@ async def fetch_overview() -> dict:
except Exception as e: # noqa: BLE001 —— 统计缺失时指数照常展示 except Exception as e: # noqa: BLE001 —— 统计缺失时指数照常展示
errors.append(f"两市统计: {str(e)[:60]}") 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 = { payload = {
"updated_at": datetime.now().isoformat(), # 字符串cache_set 走 json.dumps "fetched_at": datetime.now().isoformat(),
"fetched_ts": time.time(), # 墙钟epoch float跨进程Redis判新鲜度用
"indexes": indexes, "indexes": indexes,
"stats": stats, "stats": stats,
"amount_history": amount_history,
"errors": errors, "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 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_chgrealtime=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,
}

View File

@@ -383,24 +383,34 @@ class IndexQuoteOut(BaseModel):
close: float | None = None close: float | None = None
change: float | None = None change: float | None = None
pct_chg: float | None = None pct_chg: float | None = None
trade_date: date | None = None # 最近交易日(收盘口径 trade_date: date | None = None # 行情归属交易日(实时时为当天
spark: list[float] = [] # 近 N 日收盘(旧 -> 新),迷你走势用 spark: list[float] = [] # 近 N 日收盘(旧 -> 新),迷你走势用
spark_dates: list[str] = [] # 与 spark 对齐的交易日YYYYMMDDhover 提示用 spark_dates: list[str] = [] # 与 spark 对齐的交易日YYYYMMDDhover 提示用
realtime: bool = False # True=盘中实时腾讯False=最近收盘tushare EOD
class MarketStatsOut(BaseModel): class MarketStatsOut(BaseModel):
"""沪深两市汇总daily_info沪 SH_A + 深 SZ_MARKET亿元""" """沪深两市汇总daily_info沪 SH_MARKET(主板A+科创+B) + 深 SZ_MARKET不含基金/北交所,亿元)。"""
trade_date: date | None = None trade_date: date | None = None
total_mv: float | None = None # 总市值(亿元) total_mv: float | None = None # 总市值(亿元)
float_mv: float | None = None # 流通市值(亿元) float_mv: float | None = None # 流通市值(亿元)
amount: float | None = None # 两市成交额(亿元) amount: float | None = None # 两市成交额(亿元EOD
turnover: float | None = None # 换手率 %(沪市口径) turnover: float | None = None # 换手率 %(沪市口径)
amount_today: float | None = None # 当日实时两市成交额(亿元,腾讯;收盘后为当日终值)
class AmountBarOut(BaseModel):
"""两市成交额历史的一根柱(亿元)。"""
date: date
amount: float
intraday: bool = False # True=当日实时口径(尚未并入 EOD
class MarketOverviewResponse(BaseModel): class MarketOverviewResponse(BaseModel):
updated_at: datetime updated_at: datetime
indexes: list[IndexQuoteOut] = [] indexes: list[IndexQuoteOut] = []
stats: MarketStatsOut | None = None stats: MarketStatsOut | None = None
amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示) errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示)

View File

@@ -339,6 +339,13 @@ async def _run_sync(days: int, force: bool) -> None:
# candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效) # candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效)
await cache.bump_version("candles") 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"] = "同步完成" _sync_state["step"] = "同步完成"
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
_sync_state["error"] = f"同步失败:{str(e)[:300]}" _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) return dict(_sync_state)
# candles 是千万行表,count 较重;前端每 2s 轮询状态,需 TTL 缓存降载 # candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s
_status_stats_cache: dict = {"at": 0.0, "data": None} # 结果按 ver:candles 版本号缓存(同步完成即 bump 失效),前端 2s 轮询只付毫秒级。
_STATS_TTL = 30.0 _status_stats_cache: dict = {"at": 0.0, "ver": -1, "data": None}
_STATS_TTL = 120.0 # 进程内兜底 TTLRedis 不可用时重聚合的最小间隔)
_stats_bg_tasks: set[asyncio.Task] = set() # 后台写缓存的引用,防 GC
_stats_lock = asyncio.Lock() # 单飞锁:同步尾部的预热与轮询并发时,重聚合只跑一次
async def _db_stats(session: AsyncSession) -> dict: async def _heavy_stats(session: AsyncSession) -> dict:
"""candles/快照/股票列表实况30s TTL 缓存)""" """重聚合:行数/日期数。4 条查询走千万行表(>11s绝不能落在轮询热路径上"""
now = time.time()
if _status_stats_cache["data"] is not None and now - _status_stats_cache["at"] < _STATS_TTL:
return _status_stats_cache["data"]
stocks = int(await session.scalar(select(func.count()).select_from(StockBasic)) or 0) 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) 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) 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( n_dates = int(await session.scalar(
select(func.count(func.distinct(func.date(Candle.ts)))).where(Candle.timeframe == "1d") select(func.count(func.distinct(func.date(Candle.ts)))).where(Candle.timeframe == "1d")
) or 0) ) or 0)
data = { return {
"stocks": stocks, "daily_rows": daily_rows, "snapshot_rows": snap_rows, "stocks": stocks, "daily_rows": daily_rows,
"last_daily": last_daily, "dates": n_dates, "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_dailymax(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: async def get_sync_status(session: AsyncSession) -> dict:

View File

@@ -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: Waiting for application startup.
INFO: Application startup complete. INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: 127.0.0.1:48937 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:60648 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized INFO: 127.0.0.1:49077 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:62525 - "POST /api/auth/login HTTP/1.1" 200 OK INFO: 127.0.0.1:49092 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:62531 - "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:62573 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK INFO: 127.0.0.1:49094 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:62746 - "POST /api/screener/run HTTP/1.1" 200 OK WARNING: WatchFiles detected changes in 'scripts\diag_market_sources.py'. Reloading...
INFO: 127.0.0.1:62747 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK INFO: 127.0.0.1:52219 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:64917 - "GET /api/watchlist HTTP/1.1" 200 OK INFO: 127.0.0.1:52237 - "GET /api/preferences 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:52238 - "GET /api/market/overview 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:52239 - "GET /api/screener/sync/status 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:52861 - "GET /api/auth/me 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:52871 - "GET /api/preferences 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:52876 - "GET /api/market/overview 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:52883 - "GET /api/screener/sync/status 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:53667 - "GET /api/auth/me 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:53671 - "GET /api/market/overview 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:53670 - "GET /api/preferences 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:53678 - "GET /api/screener/sync/status 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:53710 - "GET /api/market/overview 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:53753 - "GET /api/market/overview 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:54745 - "GET /api/health 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:54746 - "POST /api/auth/login 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:59489 - "GET /api/auth/me 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:59506 - "GET /api/preferences 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:59507 - "GET /api/market/overview 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:59508 - "GET /api/screener/sync/status 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:59720 - "GET /api/auth/me 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:59721 - "GET /api/preferences 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:59727 - "GET /api/market/overview 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:59739 - "GET /api/auth/me 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:59740 - "GET /api/preferences 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:59748 - "GET /api/market/overview 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:59753 - "GET /api/auth/me 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:59756 - "GET /api/market/overview 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:59754 - "GET /api/preferences 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:59771 - "GET /api/auth/me 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:59778 - "GET /api/market/overview 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:59777 - "GET /api/preferences 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:59730 - "GET /api/screener/sync/status 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:59749 - "GET /api/screener/sync/status 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:59760 - "GET /api/screener/sync/status 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:59779 - "GET /api/screener/sync/status 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:13210 - "GET /api/health 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:16263 - "GET /api/auth/me 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:16275 - "GET /api/preferences 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:16276 - "GET /api/market/overview 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:16303 - "GET /api/auth/me 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:16309 - "GET /api/market/overview 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:16308 - "GET /api/preferences 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:16319 - "GET /api/auth/me 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:16322 - "GET /api/market/overview 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:16320 - "GET /api/preferences 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:16349 - "GET /api/auth/me 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:16357 - "GET /api/market/overview 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:16352 - "GET /api/preferences 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:16310 - "GET /api/screener/sync/status 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:16277 - "GET /api/screener/sync/status 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:16325 - "GET /api/screener/sync/status 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:16358 - "GET /api/screener/sync/status 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:16598 - "POST /api/auth/login 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:16599 - "GET /api/market/overview 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:16600 - "GET /api/market/overview 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:16601 - "GET /api/market/overview 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:16602 - "GET /api/market/overview 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

View File

@@ -388,22 +388,31 @@ export interface IndexQuote {
close: number | null; close: number | null;
change: number | null; change: number | null;
pct_chg: number | null; pct_chg: number | null;
trade_date: string | null; // 最近交易日ISO trade_date: string | null; // 行情归属交易日ISO;实时时为当天
spark: number[]; // 近 N 日收盘(旧 -> 新) spark: number[]; // 近 N 日收盘(旧 -> 新)
spark_dates: string[]; // 与 spark 对齐的交易日YYYYMMDD spark_dates: string[]; // 与 spark 对齐的交易日YYYYMMDD
realtime?: boolean; // true=盘中实时腾讯false=最近收盘EOD
} }
export interface MarketStats { export interface MarketStats {
trade_date: string | null; trade_date: string | null;
total_mv: number | null; // 沪深总市值(亿元) total_mv: number | null; // 沪深总市值(亿元)
float_mv: number | null; // 沪深流通市值(亿元) float_mv: number | null; // 沪深流通市值(亿元)
amount: number | null; // 两市成交额(亿元) amount: number | null; // 两市成交额(亿元EOD
turnover: number | null; // 换手率 %(沪市口径) turnover: number | null; // 换手率 %(沪市口径)
amount_today?: number | null; // 当日实时两市成交额(亿元;收盘后为当日终值)
}
export interface AmountBar {
date: string; // ISO 日期
amount: number; // 两市成交额(亿元)
intraday?: boolean; // true=当日实时口径(尚未并入 EOD
} }
export interface MarketOverview { export interface MarketOverview {
updated_at: string; updated_at: string;
indexes: IndexQuote[]; indexes: IndexQuote[];
stats: MarketStats | null; stats: MarketStats | null;
amount_history: AmountBar[]; // 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
errors: string[]; errors: string[];
} }

View File

@@ -4,6 +4,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { getMarketOverview } from '@/api/client'; import { getMarketOverview } from '@/api/client';
import type { IndexQuote, MarketOverview as Overview } from '@/api/types'; import type { IndexQuote, MarketOverview as Overview } from '@/api/types';
import AmountHistoryChart from '@/components/AmountHistoryChart.vue';
// 主页大盘总览A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。 // 主页大盘总览A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
// 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。 // 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。
@@ -119,7 +121,7 @@ function hoverText(it: IndexQuote): string {
<section class="mb-12" aria-label="大盘行情总览"> <section class="mb-12" aria-label="大盘行情总览">
<div class="mb-3 flex items-baseline justify-between"> <div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-medium text-[#A8AFB8]">大盘行情 <h2 class="text-sm font-medium text-[#A8AFB8]">大盘行情
<span class="ml-2 text-xs font-normal text-[#6B7280]">收盘口径 · 每晚更新</span> <span class="ml-2 text-xs font-normal text-[#6B7280]">实时行情 · 收盘走势</span>
</h2> </h2>
<div class="flex items-center gap-3 text-xs text-[#6B7280]"> <div class="flex items-center gap-3 text-xs text-[#6B7280]">
<span v-if="updatedAt">更新于 {{ updatedAt }}</span> <span v-if="updatedAt">更新于 {{ updatedAt }}</span>
@@ -167,7 +169,11 @@ function hoverText(it: IndexQuote): string {
> >
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-[#E5E7EB]">{{ it.name }}</span> <span class="text-sm font-medium text-[#E5E7EB]">{{ it.name }}</span>
<span class="font-mono text-[10px] tabular-nums text-[#6B7280]">{{ fmtDate(it.trade_date) }}</span> <!-- 实时蓝色徽标收盘口径交易日 MM-DD -->
<span v-if="it.realtime" class="flex items-center gap-1 text-[10px] text-blue-400">
<span class="h-1 w-1 animate-pulse rounded-full bg-blue-400" />实时
</span>
<span v-else class="font-mono text-[10px] tabular-nums text-[#6B7280]">{{ fmtDate(it.trade_date) }}</span>
</div> </div>
<div class="mt-1.5 flex items-baseline gap-2"> <div class="mt-1.5 flex items-baseline gap-2">
<span class="text-xl font-semibold text-white">{{ fmtClose(it.close) }}</span> <span class="text-xl font-semibold text-white">{{ fmtClose(it.close) }}</span>
@@ -220,14 +226,19 @@ function hoverText(it: IndexQuote): string {
</div> </div>
</div> </div>
<!-- 两市统计daily_info SH_A + SZ_MARKET 汇总 --> <!-- 两市统计daily_info SH_MARKET(主板A+科创+B) + SZ_MARKET 汇总 -->
<div v-if="overview.stats" class="mt-4 flex flex-wrap items-center gap-x-8 gap-y-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm"> <div
v-if="overview.stats"
class="mt-4 flex flex-wrap items-center gap-x-8 gap-y-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm"
title="口径沪市主板A+科创板+B股+ 深市全部股票,不含基金/北交所"
>
<span class="text-xs text-[#6B7280]">沪深两市 <span class="text-xs text-[#6B7280]">沪深两市
<span class="font-mono tabular-nums">{{ fmtDate(overview.stats.trade_date) }}</span> <span class="font-mono tabular-nums">{{ fmtDate(overview.stats.trade_date) }}</span>
</span> </span>
<span class="flex items-baseline gap-2"> <span class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">成交额</span> <span class="text-xs text-[#6B7280]">成交额</span>
<span class="font-semibold text-[#E5E7EB]">{{ fmtYi(overview.stats.amount) }}</span> <span class="font-semibold text-[#E5E7EB]">{{ fmtYi(overview.stats.amount_today ?? overview.stats.amount) }}</span>
<span v-if="overview.stats.amount_today != null" class="text-[10px] text-blue-300">今日</span>
</span> </span>
<span class="flex items-baseline gap-2"> <span class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">总市值</span> <span class="text-xs text-[#6B7280]">总市值</span>
@@ -243,6 +254,13 @@ function hoverText(it: IndexQuote): string {
</span> </span>
</div> </div>
<!-- 两市成交额历史数值轴柱状图今日盘中柱调淡标注 -->
<AmountHistoryChart
v-if="overview.amount_history?.length"
:bars="overview.amount_history"
class="mt-3"
/>
<!-- 部分来源失败透明但不打扰 --> <!-- 部分来源失败透明但不打扰 -->
<div v-if="overview.errors.length" class="mt-2 text-[10px] text-[#6B7280]" :title="overview.errors.join('')"> <div v-if="overview.errors.length" class="mt-2 text-[10px] text-[#6B7280]" :title="overview.errors.join('')">
{{ overview.errors.length }} 项数据获取失败已跳过 {{ overview.errors.length }} 项数据获取失败已跳过

View File

@@ -1,31 +1,6 @@
$ vite $ vite
VITE v6.4.3 ready in 717 ms VITE v6.4.3 ready in 1900 ms
➜ Local: http://localhost:5173/ ➜ Local: http://localhost:5173/
 ➜ Network: use --host to expose  ➜ 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)