"""HTTP 路由(OpenAPI 契约的载体)。 GET /api/health 健康检查 GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合) GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数) POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效 POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选 POST /api/screener/sync 启动全市场数据同步(后台任务) GET /api/screener/sync/status 同步任务状态与数据实况 """ from __future__ import annotations import bisect import json import pandas as pd from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from .backtest.engine import BacktestConfig, run_backtest from .auth import require_user from .backtest.events import EventEngineError, run_event_backtest from .backtest.strategies import build_strategy from .config import settings from .data import fetcher, repository from .data.aggregation import bars_per_year, resample_bars from .data.symbols import plain_code from .db import get_session from .domain import Bar from . import indicators as ind from .models import ( AdjFactor, BacktestRun, DailySnapshot, MarketDaily, ScreenerQuery, StockBasic, UserPreference, WatchlistItem, ) from .schemas import ( BacktestRequest, BacktestResponse, CandleOut, EquityPoint, EventBacktestRequest, EventBacktestResponse, IndicatorOut, MetricsOut, PreferencesOut, PreferencesUpdate, PreviewInfoOut, PreviewResponse, ScreenerQueryListResponse, ScreenerQueryOut, ScreenerRunRequest, ScreenerRunResponse, ScreenerSyncRequest, ScreenerSyncStatus, SignalOut, StockListItemOut, StockListResponse, StockFacetsResponse, FacetItemOut, SyncRequest, SyncResponse, WatchlistOp, ) from .screener import engine, market_sync from .screener.engine import DataNotReadyError from .screener.llm import ScreenerError, parse_conditions, parse_event_spec router = APIRouter(prefix="/api", dependencies=[Depends(require_user)]) def _series_to_jsonable(s: pd.Series) -> list[float | None]: """NaN -> None(lightweight-charts 的 whitespace data,跳过指标预热期)。""" out: list[float | None] = [] for v in s.tolist(): if v is None or (isinstance(v, float) and v != v): out.append(None) else: out.append(float(v)) return out 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] _ADJUST_MODES = ("bfq", "qfq", "hfq") def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]: """按复权因子把 K 线从 from_mode 换算到 to_mode(bfq/qfq/hfq)。 相对不复权的乘数:bfq=1,qfq=f(t)/f(latest),hfq=f(t)。 因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。 """ fd = sorted((f.trade_date.date(), float(f.adj_factor)) for f in factors) fdates = [d for d, _ in fd] f_latest = fd[-1][1] def _f_at(d) -> float: i = bisect.bisect_right(fdates, d) - 1 return fd[i][1] if i >= 0 else fd[0][1] def _mult(mode: str, f: float) -> float: if mode == "bfq": return 1.0 return f / f_latest if mode == "qfq" else f out: list[Bar] = [] for b in bars: f = _f_at(b.ts.date()) m = _mult(to_mode, f) / _mult(from_mode, f) out.append(Bar( ts=b.ts, 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, )) return out @router.get("/candles/{symbol}", response_model=list[CandleOut]) async def get_candles( symbol: str, timeframe: str = "1d", limit: int = 5000, session: AsyncSession = Depends(get_session), ) -> list[CandleOut]: # 始终以日线为基底,再聚合到目标周期 rows = await repository.get_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] @router.post("/data/sync", response_model=SyncResponse) async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_session)) -> SyncResponse: """主动拉取并缓存某标的的日线(Tushare 主 -> AKShare 兜底)。""" try: res = await fetcher.sync_symbol( session, req.symbol, start=req.start, end=req.end, source=req.source, force=req.force ) return SyncResponse(**res) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=502, detail=str(e)) # ---------- 股票列表(全市场浏览) ---------- _STOCKS_SQL = text(""" SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market, c.close AS close, p.close AS prev_close, c.ts AS last_ts, cnt.n AS bar_count, CASE WHEN c.close IS NOT NULL AND p.close IS NOT NULL AND p.close <> 0 THEN round(((c.close / p.close - 1) * 100)::numeric, 2) END AS pct_chg, (w.id IS NOT NULL) AS watched FROM stock_basic sb LEFT JOIN LATERAL ( SELECT close, ts FROM candles WHERE symbol = sb.symbol AND timeframe = '1d' ORDER BY ts DESC LIMIT 1 ) c ON true LEFT JOIN LATERAL ( SELECT close FROM candles WHERE symbol = sb.symbol AND timeframe = '1d' AND ts < c.ts ORDER BY ts DESC LIMIT 1 ) p ON c.ts IS NOT NULL LEFT JOIN LATERAL ( SELECT count(*) AS n FROM candles WHERE symbol = sb.symbol AND timeframe = '1d' ) cnt ON true LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid WHERE sb.list_status = 'L' AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch) AND (:market = '' OR sb.market = :market) AND (:industry = '' OR sb.industry = :industry) AND (:area = '' OR sb.area = :area) AND (:watched_only = false OR w.id IS NOT NULL) ORDER BY w.id DESC NULLS LAST, sb.symbol LIMIT :limit OFFSET :offset """) _STOCKS_COUNT_SQL = text(""" SELECT count(*) FROM stock_basic sb LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid WHERE sb.list_status = 'L' AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch) AND (:market = '' OR sb.market = :market) AND (:industry = '' OR sb.industry = :industry) AND (:area = '' OR sb.area = :area) AND (:watched_only = false OR w.id IS NOT NULL) """) @router.get("/stocks", response_model=StockListResponse) async def list_stocks( search: str = "", market: str = "", industry: str = "", area: str = "", watched_only: bool = False, limit: int = 100, offset: int = 0, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> StockListResponse: """全市场股票列表:stock_basic 基本信息 + candles 最新行情(本地缓存,无缓存则行情列为空)。 自选股(watchlist_items)排最前;watched_only=true 只看自选。""" search = search.strip() limit = max(1, min(limit, 500)) offset = max(0, offset) params = { "search": search, "psearch": f"%{search}%", "market": market, "industry": industry, "area": area, "watched_only": watched_only, "uid": user.id, "limit": limit, "offset": offset, } total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one() rows = (await session.execute(_STOCKS_SQL, params)).mappings().all() return StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows]) @router.get("/stocks/facets", response_model=StockFacetsResponse) async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFacetsResponse: """看股页筛选项:行业 / 地域(含数量,按数量降序)。""" industries = ( await session.execute(text(""" SELECT industry AS name, count(*) AS n FROM stock_basic WHERE list_status = 'L' AND industry IS NOT NULL AND industry <> '' GROUP BY industry ORDER BY n DESC """)) ).mappings().all() areas = ( await session.execute(text(""" SELECT area AS name, count(*) AS n FROM stock_basic WHERE list_status = 'L' AND area IS NOT NULL AND area <> '' GROUP BY area ORDER BY n DESC """)) ).mappings().all() return StockFacetsResponse( industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries], areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas], ) @router.post("/backtest", response_model=BacktestResponse) async def backtest( req: BacktestRequest, session: AsyncSession = Depends(get_session), ) -> BacktestResponse: # 真实数据:本地无缓存则先拉取 if not await fetcher.is_cached(session, req.symbol): try: await fetcher.sync_symbol(session, req.symbol, source="auto") except Exception as e: # noqa: BLE001 raise HTTPException(status_code=502, detail=f"数据拉取失败: {e}") # 日线为基底,聚合到请求周期 rows = await repository.get_candles( session, req.symbol, "1d", start=req.start, end=req.end, limit=100000 ) if not rows: raise HTTPException(status_code=404, detail=f"无数据: symbol={req.symbol}") bars = resample_bars(_rows_to_bars(rows), req.timeframe) if len(bars) < 2: raise HTTPException(status_code=400, detail=f"周期 {req.timeframe} 下数据不足,无法回测") try: strategy = build_strategy(req.strategy, req.params) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=400, detail=f"策略构建失败: {e}") cfg = BacktestConfig( initial_cash=req.initial_cash, fast_mode=req.fast_mode, bars_per_year=bars_per_year(req.timeframe), ) result = run_backtest(bars, strategy, cfg) df: pd.DataFrame = result["df"] m = result["metrics"] # 记录到回测运行注册表(可复现/可审计的基础) session.add( BacktestRun( symbol=req.symbol, strategy=req.strategy, timeframe=req.timeframe, params_json=json.dumps(req.params, ensure_ascii=False), initial_cash=req.initial_cash, total_return=m["total_return"], max_drawdown=m["max_drawdown"], sharpe=m["sharpe"], num_trades=m["num_trades"], ) ) await session.commit() candles = [ CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"], close=r["close"], volume=r["volume"]) for _, r in df.iterrows() ] signals = [ SignalOut(ts=f.ts, side=f.side.value, price=f.price, qty=f.qty) for f in result["fills"] ] indicators = IndicatorOut( strategy=req.strategy, data={col: _series_to_jsonable(df[col]) for col in result["indicator_cols"]}, ) equity = [EquityPoint(ts=t.to_pydatetime(), value=float(v)) for t, v in result["equity"].items()] return BacktestResponse( symbol=req.symbol, timeframe=req.timeframe, strategy=req.strategy, candles=candles, indicators=indicators, signals=signals, equity=equity, metrics=MetricsOut(**m), final_cash=result["final_cash"], final_position=result["final_position"], initial_cash=req.initial_cash, ) @router.post("/backtest/event", response_model=EventBacktestResponse) async def backtest_event( req: EventBacktestRequest, session: AsyncSession = Depends(get_session), ) -> EventBacktestResponse: """自然语言事件回测:入场条件命中 -> 次日买入 -> 持有 N 日,单股或全市场汇总统计。 直传 spec 则跳过 LLM(前端调参重跑)。""" try: spec = req.spec or await parse_event_spec(req.text) result = await run_event_backtest( session, spec, ts_code=req.ts_code, start=req.start.date() if req.start else None, end=req.end.date() if req.end else None, ) except ScreenerError as e: raise HTTPException(status_code=502, detail=str(e)) except EventEngineError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=500, detail=f"事件回测失败: {e}") return EventBacktestResponse( text=req.text, spec=result["spec"], universe=result["universe"], start=result["start"], end=result["end"], stats=result["stats"], trades=result["trades"], total=result["total"], ) # ---------- 智能选股 ---------- @router.post("/screener/run", response_model=ScreenerRunResponse) async def screener_run( req: ScreenerRunRequest, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> ScreenerRunResponse: """自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。 成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。""" try: conds = req.conditions or await parse_conditions(req.text) if not conds.indicator and not conds.snapshot: raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法") result = await engine.run_screen(session, conds, settings.screener_default_limit) # 相同文本 + 相同条件的上一条不重复记录(一键重跑场景) exists = ( await session.execute( select(ScreenerQuery.id).where( ScreenerQuery.user_id == user.id, ScreenerQuery.text == req.text.strip(), ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False), ) ) ).scalar_one_or_none() if exists is None: session.add(ScreenerQuery( user_id=user.id, text=req.text.strip(), conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False), hit_count=result.get("total", 0), )) await session.commit() return ScreenerRunResponse(**result) except HTTPException: raise except DataNotReadyError as e: raise HTTPException(status_code=409, detail=str(e)) except ValueError as e: # 未知指标/字段、条件为空 raise HTTPException(status_code=400, detail=str(e)) except ScreenerError as e: code = 503 if "未配置 LLM_API_KEY" in str(e) else 502 raise HTTPException(status_code=code, detail=str(e)) @router.get("/screener/queries", response_model=ScreenerQueryListResponse) async def screener_queries( limit: int = 20, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> ScreenerQueryListResponse: """当前用户的提问历史(最新在前,含解析出的条件与命中数,可一键重跑)。""" limit = max(1, min(limit, 100)) rows = ( await session.execute( select(ScreenerQuery) .where(ScreenerQuery.user_id == user.id) .order_by(ScreenerQuery.created_at.desc()) .limit(limit) ) ).scalars().all() items = [] for r in rows: conds = None if r.conditions_json: try: from .schemas import ScreenConditions conds = ScreenConditions.model_validate_json(r.conditions_json) except Exception: # noqa: BLE001 —— 旧格式/解析失败则只展示文本 conds = None items.append(ScreenerQueryOut( id=r.id, text=r.text, conditions=conds, hit_count=r.hit_count, created_at=r.created_at )) return ScreenerQueryListResponse(items=items) @router.delete("/screener/queries/{query_id}", status_code=204) async def screener_query_delete( query_id: int, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> None: await session.execute( text("DELETE FROM screener_queries WHERE id = :i AND user_id = :u"), {"i": query_id, "u": user.id}, ) await session.commit() # ---------- 用户偏好 ---------- @router.get("/preferences", response_model=PreferencesOut) async def get_preferences( session: AsyncSession = Depends(get_session), user=Depends(require_user) ) -> PreferencesOut: prefs: dict[str, object] = {} rows = ( await session.execute(select(UserPreference).where(UserPreference.user_id == user.id)) ).scalars().all() for r in rows: try: prefs[r.key] = json.loads(r.value_json) except Exception: # noqa: BLE001 prefs[r.key] = None return PreferencesOut(prefs=prefs) @router.put("/preferences", response_model=PreferencesOut) async def put_preferences( req: PreferencesUpdate, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> PreferencesOut: """部分更新:只覆盖出现的 key;值为 null 表示删除该 key。返回更新后的全量。""" for key, value in req.prefs.items(): if not key or len(key) > 64: continue if value is None: await session.execute( text("DELETE FROM user_preferences WHERE user_id = :u AND key = :k"), {"u": user.id, "k": key}, ) continue existing = ( await session.execute( select(UserPreference).where( UserPreference.user_id == user.id, UserPreference.key == key ) ) ).scalars().first() vj = json.dumps(value, ensure_ascii=False) if existing: existing.value_json = vj else: session.add(UserPreference(user_id=user.id, key=key, value_json=vj)) await session.commit() return await get_preferences(session=session, user=user) # ---------- 自选股 ---------- @router.get("/watchlist", response_model=list[str]) async def get_watchlist( session: AsyncSession = Depends(get_session), user=Depends(require_user) ) -> list[str]: """当前用户自选股 ts_code 列表(加入时间倒序)。""" rows = ( await session.execute( select(WatchlistItem.ts_code) .where(WatchlistItem.user_id == user.id) .order_by(WatchlistItem.created_at.desc(), WatchlistItem.id.desc()) ) ).scalars().all() return list(rows) @router.post("/watchlist", response_model=list[str]) async def add_watchlist( req: WatchlistOp, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> list[str]: exists = ( await session.execute( select(WatchlistItem.id).where( WatchlistItem.user_id == user.id, WatchlistItem.ts_code == req.ts_code ) ) ).scalar_one_or_none() if exists is None: session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code)) await session.commit() return await get_watchlist(session=session, user=user) @router.delete("/watchlist/{ts_code}", response_model=list[str]) async def remove_watchlist( ts_code: str, session: AsyncSession = Depends(get_session), user=Depends(require_user), ) -> list[str]: await session.execute( text("DELETE FROM watchlist_items WHERE user_id = :u AND ts_code = :c"), {"u": user.id, "c": ts_code}, ) await session.commit() return await get_watchlist(session=session, user=user) @router.post("/screener/sync", response_model=ScreenerSyncStatus) async def screener_sync_start( req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session) ) -> ScreenerSyncStatus: """启动全市场数据同步(后台任务,立即返回状态)。""" try: await market_sync.start_sync(session, req.days, req.force) except ScreenerError as e: raise HTTPException(status_code=503, detail=str(e)) status = await market_sync.get_sync_status(session) return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields}) @router.get("/screener/sync/status", response_model=ScreenerSyncStatus) async def screener_sync_status(session: AsyncSession = Depends(get_session)) -> ScreenerSyncStatus: """同步任务状态 + 数据实况(最新交易日/行数/ready)。""" status = await market_sync.get_sync_status(session) return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields}) @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", session: AsyncSession = Depends(get_session), ) -> PreviewResponse: """个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq, 未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。 timeframe 聚合到周/月/年(先复权再聚合);mas 指定主图 MA 周期(逗号分隔)。""" 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"): raise HTTPException(status_code=400, detail="timeframe 仅支持 1d/1w/1M/1y") try: ma_periods = sorted({int(p) for p in mas.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500}) except ValueError: raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60") if not ma_periods: ma_periods = [5, 10, 20, 60] symbol = plain_code(ts_code) # 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源 md = ( await session.execute( select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date.desc()).limit(1) ) ).scalars().first() # --- 日线:candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) --- # fetcher 增量拉取写入的是 qfq(settings.data_adjust),此时底座模式记为 qfq。 rows = await repository.get_candles(session, symbol, "1d", limit=100000) 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 = [] bars = _rows_to_bars(rows) if not bars: 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) for r in res.scalars() ] if not bars: raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)") # --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) --- if adjust != mode: factors = ( await session.execute( select(AdjFactor).where(AdjFactor.ts_code == ts_code).order_by(AdjFactor.trade_date) ) ).scalars().all() if factors: bars = _adjust_bars(bars, factors, mode, adjust) mode = adjust if source != "market": source = adjust # --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 --- 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")}, } limit = max(30, min(limit, len(bars))) for group in indicators.values(): for key in group: group[key] = group[key][-limit:] # --- 信息卡:stock_basic + 最新 market_daily + 与其对齐的快照(避免混用不同交易日) --- sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first() ds = None if md is not None: # 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见) ds = ( await session.execute( select(DailySnapshot).where( DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == md.trade_date ) ) ).scalars().first() if ds is None: ds = ( await session.execute( select(DailySnapshot).where(DailySnapshot.ts_code == ts_code).order_by(DailySnapshot.trade_date.desc()).limit(1) ) ).scalars().first() def _yi(v) -> float | None: if v is None: return None v = float(v) return None if v != v else round(v / 1e4, 2) # 万元 -> 亿元 info = PreviewInfoOut( ts_code=ts_code, symbol=symbol, name=sb.name if sb else ts_code, industry=sb.industry if sb else None, area=sb.area if sb else None, market=sb.market if sb else None, list_date=sb.list_date if sb else None, trade_date=md.trade_date if md else None, open=md.open if md else None, high=md.high if md else None, low=md.low if md else None, close=md.close if md else None, pre_close=md.pre_close if md else None, pct_chg=md.pct_chg if md else None, volume_hand=round(md.vol, 0) if md else None, amount_yi=round(md.amount / 100000, 2) if md else None, # 千元 -> 亿元 turnover_rate=ds.turnover_rate if ds else None, pe_ttm=ds.pe_ttm if ds else None, pb=ds.pb if ds else None, total_mv=_yi(ds.total_mv) if ds else None, circ_mv=_yi(ds.circ_mv) if ds else None, ) candles = [ CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars[-limit:] ] return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators)