diff --git a/backend/app/api.py b/backend/app/api.py index 848abd1..9fa7f7b 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -12,6 +12,7 @@ from __future__ import annotations import bisect import json +from datetime import datetime import pandas as pd from fastapi import APIRouter, Depends, HTTPException @@ -86,7 +87,13 @@ def _series_to_jsonable(s: pd.Series) -> list[float | None]: def _rows_to_bars(rows) -> list[Bar]: - return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows] + return [ + Bar( + ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume, + amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None), + ) + for r in rows + ] _ADJUST_MODES = ("bfq", "qfq", "hfq") @@ -120,6 +127,8 @@ def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list open=round(b.open * m, 3), high=round(b.high * m, 3), low=round(b.low * m, 3), close=round(b.close * m, 3), volume=b.volume, + # 成交额/换手率是名义量,不随复权缩放 + amount=b.amount, turnover=b.turnover, )) return out @@ -131,10 +140,14 @@ async def get_candles( limit: int = 5000, session: AsyncSession = Depends(get_session), ) -> list[CandleOut]: - # 始终以日线为基底,再聚合到目标周期 - rows = await repository.get_candles(session, symbol, "1d", limit=limit) + # 始终以日线为基底,再聚合到目标周期(取最新 limit 根) + rows = await repository.get_recent_candles(session, symbol, "1d", limit=limit) bars = resample_bars(_rows_to_bars(rows), timeframe) - return [CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars] + return [ + CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, + volume=b.volume, amount=b.amount, turnover=b.turnover) + for b in bars + ] @router.post("/data/sync", response_model=SyncResponse) @@ -305,7 +318,9 @@ async def backtest( candles = [ CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"], - close=r["close"], volume=r["volume"]) + close=r["close"], volume=r["volume"], + amount=r["amount"] if "amount" in df.columns else None, + turnover=r["turnover"] if "turnover" in df.columns else None) for _, r in df.iterrows() ] signals = [ @@ -576,11 +591,14 @@ async def screener_sync_status(session: AsyncSession = Depends(get_session)) -> @router.get("/screener/preview/{ts_code}", response_model=PreviewResponse) async def screener_preview( ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60", + end: str | None = None, session: AsyncSession = Depends(get_session), ) -> PreviewResponse: """个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq, 未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。 - timeframe 聚合到周/月/年(先复权再聚合);mas 指定主图 MA 周期(逗号分隔)。""" + timeframe 聚合到周/月/年(先复权再聚合);mas 指定主图 MA 周期(逗号分隔)。 + end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标), + has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。""" if adjust not in _ADJUST_MODES: raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}") if timeframe not in ("1d", "1w", "1M", "1y"): @@ -591,6 +609,13 @@ async def screener_preview( raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60") if not ma_periods: ma_periods = [5, 10, 20, 60] + limit = max(30, min(limit, 5000)) + end_dt: datetime | None = None + if end: + try: + end_dt = datetime.strptime(end.strip()[:10], "%Y-%m-%d") + except ValueError: + raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD") symbol = plain_code(ts_code) # 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源 @@ -601,36 +626,48 @@ async def screener_preview( ).scalars().first() # --- 日线:candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) --- - # fetcher 增量拉取写入的是 qfq(settings.data_adjust),此时底座模式记为 qfq。 - rows = await repository.get_candles(session, symbol, "1d", limit=100000) + # fetcher 现在只做「不复权」增量 upsert,底座口径恒为 bfq(TDX 全量 + Tushare 增量), + # 复权(qfq/hfq)读取时按 adj_factor 表本地换算,mode 无需再推断。 + # 每次只取「窗口 + 800 根预热」行(MA250/MACD EMA 在 800 根内充分收敛),不拉全量: + # 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。 + frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe] + fetch_n = min(100000, limit * frame_mult + 800) source = "bfq" mode = "bfq" - try: - if not rows: - await fetcher.sync_symbol(session, symbol, source="auto") - rows = await repository.get_candles(session, symbol, "1d", limit=100000) - mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq" - elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date(): - await fetcher.sync_symbol(session, symbol, source="auto", force=True) - rows = await repository.get_candles(session, symbol, "1d", limit=100000) - mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq" - except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500) - await session.rollback() - if not rows: - rows = [] + if end_dt is not None: + # 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览) + rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n) + else: + # 注意取「最新 fetch_n 根」而非最旧:get_candles 是 asc+limit(取最旧),窗口化后首屏会停在过期日期 + rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n) + try: + if not rows: + await fetcher.sync_symbol(session, symbol, source="auto") + rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n) + elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date(): + await fetcher.sync_symbol(session, symbol, source="auto", force=True) + rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n) + except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500) + await session.rollback() + if not rows: + rows = [] bars = _rows_to_bars(rows) - if not bars: + if not bars and end_dt is None: source = "market" res = await session.execute( select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date) ) bars = [ - Bar(ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.vol * 100.0) + Bar( + ts=r.trade_date, 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 else None, # 千元 -> 元 + ) for r in res.scalars() ] - if not bars: + if not bars and end_dt is None: raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)") + # 翻页到底(end 之前无数据):返回空页 + has_more=False,前端停止向前翻页 # --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) --- if adjust != mode: @@ -648,27 +685,30 @@ async def screener_preview( # --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 --- bars = resample_bars(bars, timeframe) - # --- 指标(在全量历史上计算后截尾,保证预热正确) --- - df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]}) - closes, highs, lows = df["close"], df["high"], df["low"] - macd = ind.macd(closes) - kdj = ind.kdj(highs, lows, closes) - boll = ind.bollinger(closes) - indicators: dict[str, dict[str, list[float | None]]] = { - "ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods}, - "macd": { - "dif": _series_to_jsonable(macd["macd"]), - "dea": _series_to_jsonable(macd["signal"]), - "hist": _series_to_jsonable(macd["hist"]), - }, - "kdj": {k: _series_to_jsonable(kdj[k]) for k in ("k", "d", "j")}, - "rsi": { - "rsi6": _series_to_jsonable(ind.rsi(closes, 6)), - "rsi12": _series_to_jsonable(ind.rsi(closes, 12)), - "rsi24": _series_to_jsonable(ind.rsi(closes, 24)), - }, - "boll": {k: _series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")}, - } + # --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过) --- + has_more = len(bars) > limit # 返回窗口之前还有更早历史(含预热行) + indicators: dict[str, dict[str, list[float | None]]] = {} + if bars: + df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]}) + closes, highs, lows = df["close"], df["high"], df["low"] + macd = ind.macd(closes) + kdj = ind.kdj(highs, lows, closes) + boll = ind.bollinger(closes) + indicators = { + "ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods}, + "macd": { + "dif": _series_to_jsonable(macd["macd"]), + "dea": _series_to_jsonable(macd["signal"]), + "hist": _series_to_jsonable(macd["hist"]), + }, + "kdj": {k: _series_to_jsonable(kdj[k]) for k in ("k", "d", "j")}, + "rsi": { + "rsi6": _series_to_jsonable(ind.rsi(closes, 6)), + "rsi12": _series_to_jsonable(ind.rsi(closes, 12)), + "rsi24": _series_to_jsonable(ind.rsi(closes, 24)), + }, + "boll": {k: _series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")}, + } limit = max(30, min(limit, len(bars))) for group in indicators.values(): for key in group: @@ -724,7 +764,8 @@ async def screener_preview( ) candles = [ - CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) + CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, + volume=b.volume, amount=b.amount, turnover=b.turnover) for b in bars[-limit:] ] - return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators) + return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators, has_more=has_more) diff --git a/backend/app/data/aggregation.py b/backend/app/data/aggregation.py index 11f0a7c..baf0cb2 100644 --- a/backend/app/data/aggregation.py +++ b/backend/app/data/aggregation.py @@ -4,6 +4,7 @@ MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。 OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。 +成交额/换手率为名义量:求和(全缺则保持 None,不伪造 0)。 """ from __future__ import annotations @@ -22,6 +23,13 @@ def bars_per_year(timeframe: str) -> int: return _BARS_PER_YEAR.get(timeframe, 252) +def _sum_or_none(s: pd.Series): + """求和;全为 NaN 返回 None(部分缺失则忽略缺失项求和)。""" + if s.isna().all(): + return None + return float(s.sum()) + + def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]: """把日线 bars 聚合为目标周期;日线或未知周期原样返回。""" if not bars or timeframe in ("1d", "d", "day", "", None): @@ -31,14 +39,18 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]: return bars df = pd.DataFrame( - [{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume} + [{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, + "volume": b.volume, + "amount": b.amount if b.amount is not None else float("nan"), + "turnover": b.turnover if b.turnover is not None else float("nan")} for b in bars] ).set_index("ts").sort_index() agg = ( df.resample(rule) - .agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) - .dropna() + .agg({"open": "first", "high": "max", "low": "min", "close": "last", + "volume": "sum", "amount": _sum_or_none, "turnover": _sum_or_none}) + .dropna(subset=["open"]) ) return [ @@ -49,6 +61,8 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]: low=float(row["low"]), close=float(row["close"]), volume=float(row["volume"]), + amount=row["amount"] if row["amount"] == row["amount"] else None, # NaN -> None + turnover=row["turnover"] if row["turnover"] == row["turnover"] else None, ) for ts, row in agg.iterrows() ] diff --git a/backend/app/data/akshare_provider.py b/backend/app/data/akshare_provider.py index 80c64ba..2971f8c 100644 --- a/backend/app/data/akshare_provider.py +++ b/backend/app/data/akshare_provider.py @@ -27,12 +27,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None, bars: list[Bar] = [] for _, r in df.iterrows(): + amt = r.get("成交额") bars.append( Bar( ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"), open=float(r["开盘"]), high=float(r["最高"]), low=float(r["最低"]), close=float(r["收盘"]), volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股 + amount=float(amt) if amt is not None and amt == amt else None, # AKShare 成交额单位为元 ) ) return bars diff --git a/backend/app/data/fetcher.py b/backend/app/data/fetcher.py index 6295501..baa72cd 100644 --- a/backend/app/data/fetcher.py +++ b/backend/app/data/fetcher.py @@ -1,12 +1,15 @@ """数据编排:拉取(Tushare 主 -> AKShare 兜底)+ 本地缓存。 -真实行情落库到 candles 表(timeframe='1d'),回测统一从库读。 +真实行情落库到 candles 表(timeframe='1d',**不复权底座**),回测统一从库读。 +复权(qfq/hfq)在读取时按 adj_factor 表本地换算,见 api._adjust_bars。 """ + from __future__ import annotations import asyncio -from sqlalchemy import delete, func, select +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings @@ -40,6 +43,13 @@ async def is_cached(session: AsyncSession, symbol: str) -> bool: return await count_cached(session, symbol) > 0 +async def _last_cached_ts(session: AsyncSession, symbol: str): + res = await session.execute( + select(func.max(Candle.ts)).where(Candle.symbol == symbol, Candle.timeframe == "1d") + ) + return res.scalar() + + async def sync_symbol( session: AsyncSession, code: str, @@ -48,34 +58,57 @@ async def sync_symbol( source: str = "auto", force: bool = False, ) -> dict: - """拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。""" - if not force and await is_cached(session, code): + """增量拉取并 upsert 某标的日线(**不复权**底座)。 + + - 永不删除已有行:按 (symbol, timeframe, ts) 主键 upsert, + 不会把 TDX 导入的 30 年历史冲掉; + - 已缓存时从最后一根的次日开始增量拉取(force 仅跳过「有缓存就返回」 + 的短路,用于缓存落后于最新交易日时的刷新); + - 拉不到新行时保持原缓存不动。 + """ + last_ts = await _last_cached_ts(session, code) + if last_ts is not None and not force and not start: return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"} + if last_ts is not None and not start: + # 增量:从缓存最后一根当天开始(重叠一天重新拉取,容忍数据源漏行/盘后修订) + start = last_ts.strftime("%Y%m%d") start = start or DEFAULT_START - adjust = settings.data_adjust errors: list[str] = [] bars: list[Bar] = [] used = None for name, fn in _providers(source): try: - # tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环 - bars = await asyncio.to_thread(fn, code, start, end, adjust) + # tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环; + # adjust=None -> 不复权(复权在读取时按 adj_factor 换算) + bars = await asyncio.to_thread(fn, code, start, end, None) used = name break except Exception as e: # noqa: BLE001 errors.append(f"{name}: {e}") if not bars: + if last_ts is not None: + # 增量失败(如停牌/新股无新行):保留缓存,不算错误 + return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"} raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源") - # 全量替换该标的日线(避免重复主键) - await session.execute(delete(Candle).where(Candle.symbol == code, Candle.timeframe == "1d")) - for b in bars: - session.add( - Candle(symbol=code, timeframe="1d", ts=b.ts, open=b.open, high=b.high, - low=b.low, close=b.close, volume=b.volume) - ) + # upsert:不 delete,避免破坏既有底座(TDX 全量历史) + stmt = pg_insert(Candle).values([ + {"symbol": code, "timeframe": "1d", "ts": b.ts, "open": b.open, "high": b.high, + "low": b.low, "close": b.close, "volume": b.volume, + "amount": b.amount, "turnover": b.turnover} + for b in bars + ]) + stmt = stmt.on_conflict_do_update( + index_elements=["symbol", "timeframe", "ts"], + set_={"open": stmt.excluded.open, "high": stmt.excluded.high, "low": stmt.excluded.low, + "close": stmt.excluded.close, "volume": stmt.excluded.volume, + # 增量源缺失额/换手时保留库里的旧值(如 TDX 已回补的 30 年成交额) + "amount": func.coalesce(stmt.excluded.amount, Candle.amount), + "turnover": func.coalesce(stmt.excluded.turnover, Candle.turnover)}, + ) + await session.execute(stmt) await session.commit() return {"symbol": code, "bars": len(bars), "source": used} diff --git a/backend/app/data/repository.py b/backend/app/data/repository.py index eb0530d..13e70f7 100644 --- a/backend/app/data/repository.py +++ b/backend/app/data/repository.py @@ -32,3 +32,38 @@ async def get_candles( stmt = stmt.order_by(Candle.ts.asc()).limit(limit) result = await session.execute(stmt) return list(result.scalars().all()) + + +async def get_recent_candles( + session: AsyncSession, + symbol: str, + timeframe: str = "1d", + limit: int = 5000, +) -> list[Candle]: + """取最近 limit 根 K 线(含最新交易日),按时间升序返回。""" + stmt = ( + select(Candle) + .where(Candle.symbol == symbol, Candle.timeframe == timeframe) + .order_by(Candle.ts.desc()) + .limit(limit) + ) + result = await session.execute(stmt) + return list(reversed(result.scalars().all())) + + +async def get_candles_before( + session: AsyncSession, + symbol: str, + timeframe: str, + before: datetime, + limit: int = 5000, +) -> list[Candle]: + """取 before 之前(不含)的最近 limit 根 K 线,按时间升序返回(历史向前翻页用)。""" + stmt = ( + select(Candle) + .where(Candle.symbol == symbol, Candle.timeframe == timeframe, Candle.ts < before) + .order_by(Candle.ts.desc()) + .limit(limit) + ) + result = await session.execute(stmt) + return list(reversed(result.scalars().all())) diff --git a/backend/app/data/tushare_provider.py b/backend/app/data/tushare_provider.py index d807489..1f00d6c 100644 --- a/backend/app/data/tushare_provider.py +++ b/backend/app/data/tushare_provider.py @@ -40,12 +40,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None, df = df.sort_values("trade_date") bars: list[Bar] = [] for _, r in df.iterrows(): + amt = r.get("amount") bars.append( Bar( ts=_parse(r["trade_date"]), open=float(r["open"]), high=float(r["high"]), low=float(r["low"]), close=float(r["close"]), volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股 + amount=float(amt) * 1000.0 if amt is not None and amt == amt else None, # 千元 -> 元 ) ) return bars diff --git a/backend/app/domain.py b/backend/app/domain.py index 914d186..86109f0 100644 --- a/backend/app/domain.py +++ b/backend/app/domain.py @@ -30,13 +30,19 @@ class Timeframe(str, Enum): @dataclass(frozen=True) class Bar: - """一根 K 线(OHLCV + 时间戳)。复权标识后续扩展。""" + """一根 K 线(OHLCV + 时间戳)。复权标识后续扩展。 + + amount(成交额,元)与 turnover(换手率 %)是名义量, + 不随复权换算缩放;周期聚合时求和。缺数据为 None。 + """ ts: datetime open: float high: float low: float close: float volume: float + amount: float | None = None + turnover: float | None = None @dataclass(frozen=True) diff --git a/backend/app/models.py b/backend/app/models.py index e0253b5..80bafe0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -33,6 +33,8 @@ class Candle(Base): low: Mapped[float] = mapped_column(Float) close: Mapped[float] = mapped_column(Float) volume: Mapped[float] = mapped_column(Float) + amount: Mapped[float | None] = mapped_column(Float) # 成交额(元);TDX 原生 float32 + turnover: Mapped[float | None] = mapped_column(Float) # 换手率 %(daily_basic,2000 年起) __table_args__ = ( UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4c4b856..ae48d29 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -18,6 +18,8 @@ class CandleOut(BaseModel): low: float close: float volume: float + amount: float | None = None # 成交额(元);无数据为 null + turnover: float | None = None # 换手率 %;无数据为 null model_config = {"from_attributes": True} @@ -264,6 +266,7 @@ class PreviewResponse(BaseModel): candles: list[CandleOut] indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict) # indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}} + has_more: bool = False # 返回窗口之前是否还有更早历史(前端向左滚动翻页用) # ---------- Auth ---------- diff --git a/frontend/index.html b/frontend/index.html index 218479d..828984b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,9 +3,11 @@
+ ++
支持 KDJ / RSI / MACD / 布林 / 均线指标条件,市值 / 市盈率 / 换手率等快照条件,以及「连续 N 天」「近 N 天任一天」时间窗口。 - 按 Ctrl+Enter 快速筛选。 + 按 Ctrl+Enter 快速筛选。
| {{ c.label }} @@ -98,7 +98,7 @@ function fmtInd(it: ScreenerItemOut, key: string) { | {{ col.label }} @@ -111,33 +111,33 @@ function fmtInd(it: ScreenerItemOut, key: string) { | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ it.ts_code }} | -{{ it.name }} | +{{ it.ts_code }} | +{{ it.name }} | {{ fmt2(it.close) }} | {{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }} | -{{ fmt2(it.total_mv) }} | -{{ fmt2(it.circ_mv) }} | -{{ fmt2(it.pe_ttm) }} | -{{ fmt2(it.pb) }} | -{{ fmt2(it.turnover_rate) }} | -+ | {{ fmt2(it.total_mv) }} | +{{ fmt2(it.circ_mv) }} | +{{ fmt2(it.pe_ttm) }} | +{{ fmt2(it.pb) }} | +{{ fmt2(it.turnover_rate) }} | +{{ fmtInd(it, col.key) }} |
|
| 没有符合条件的股票 | +没有符合条件的股票 |