1489 lines
65 KiB
Python
1489 lines
65 KiB
Python
"""HTTP 路由(OpenAPI 契约的载体)。
|
||
|
||
GET /api/health 健康检查
|
||
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
|
||
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
|
||
GET /api/stocks/{ts_code}/company 个股公司简介(tushare stock_company 按需懒加载)
|
||
GET /api/stocks/{ts_code}/finance 个股财务数据(fina_indicator+三大报表关键值,近五年懒加载)
|
||
GET /api/stocks/{ts_code}/dividends 个股分红送股(tushare dividend 全历史懒加载)
|
||
GET /api/stocks/{ts_code}/reference/{kind} 个股参考数据(top10/质押/回购/解禁/大宗/股东等 11 类懒加载)
|
||
GET /api/etfs 全市场场内 ETF 列表(东财快照 + candles 最新行情)
|
||
POST /api/etf/sync 启动全市场 ETF 数据同步(后台任务)
|
||
GET /api/etf/sync/status ETF 同步任务状态与数据实况
|
||
GET /api/market/overview 主页大盘总览(中美指数 + 两市市值成交统计)
|
||
GET /api/market/index-candles 上证指数全量 K 线(1d/1w/1M/1y 聚合)
|
||
GET /api/market/global-indexes 国际指数卡片列表(index_global 21 指数)
|
||
GET /api/market/indexes/{code} 指数详情(行情 + index_basic + 估值)
|
||
GET /api/market/indexes/{code}/candles 白名单指数全量 K 线(1d/1w/1M/1y 聚合)
|
||
GET /api/market/indexes/{code}/weights 指数成分股权重(index_weight 最近月度)
|
||
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
|
||
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
|
||
POST /api/screener/sync 启动全市场数据同步(后台任务)
|
||
GET /api/screener/sync/status 同步任务状态与数据实况
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import bisect
|
||
import json
|
||
from datetime import datetime
|
||
|
||
import pandas as pd
|
||
from pydantic import TypeAdapter
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy import delete, func, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.sql.elements import TextClause
|
||
|
||
from .backtest.engine import BacktestConfig, run_backtest
|
||
from . import cache
|
||
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 company as company_mod
|
||
from .data import dividend as dividend_mod
|
||
from .data import finance as finance_mod
|
||
from .data import reference as reference_mod
|
||
from .data import fetcher, repository
|
||
from .data import etf_sync as etf_sync_mod
|
||
from .data import index_global as index_mod
|
||
from .data.aggregation import bars_per_year, resample_bars
|
||
from .data.index_series import SH_INDEX, get_index_daily
|
||
from .data.market_overview import MarketOverviewError, fetch_overview
|
||
from .data.symbols import is_etf_symbol, plain_code, to_ts_code
|
||
from .db import async_session, get_session
|
||
from .domain import Bar
|
||
from . import indicators as ind
|
||
from .trades import parse_statement
|
||
from .models import (
|
||
AdjFactor,
|
||
BacktestRun,
|
||
Candle,
|
||
ScreenerQuery,
|
||
StockBasic,
|
||
TradeCalendar,
|
||
UserPreference,
|
||
UserTrade,
|
||
WatchlistItem,
|
||
)
|
||
from .schemas import (
|
||
BacktestRequest,
|
||
BacktestResponse,
|
||
CandleOut,
|
||
EquityPoint,
|
||
EventBacktestRequest,
|
||
EventBacktestResponse,
|
||
EtfListItemOut,
|
||
EtfListResponse,
|
||
EtfSyncRequest,
|
||
EtfSyncStatus,
|
||
GlobalIndexListResponse,
|
||
IndexBasicOut,
|
||
IndexDetailResponse,
|
||
IndexQuoteBriefOut,
|
||
IndexValuationPointOut,
|
||
IndexWeightItemOut,
|
||
IndexWeightsResponse,
|
||
IndicatorOut,
|
||
MarketOverviewResponse,
|
||
MetricsOut,
|
||
PreferencesOut,
|
||
PreferencesUpdate,
|
||
PreviewInfoOut,
|
||
PreviewResponse,
|
||
ScreenerQueryListResponse,
|
||
ScreenerQueryOut,
|
||
ScreenerRunRequest,
|
||
ScreenerRunResponse,
|
||
ScreenerSyncRequest,
|
||
ScreenerSyncStatus,
|
||
SignalOut,
|
||
StockCompanyOut,
|
||
StockDividendOut,
|
||
StockFinanceOut,
|
||
StockReferenceOut,
|
||
StockListItemOut,
|
||
StockListResponse,
|
||
StockFacetsResponse,
|
||
FacetItemOut,
|
||
SyncRequest,
|
||
SyncResponse,
|
||
TradesClearResponse,
|
||
TradesImportResponse,
|
||
UserTradeOut,
|
||
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 _raw_json(resp) -> str:
|
||
"""pydantic-core(Rust)序列化:与 response_model 直返时的字节完全一致(紧凑分隔符、
|
||
非 ASCII 直出、浮点小数形式),且比 stdlib json.dumps 快。大响应(preview ~250KB)
|
||
命中缓存时直接 Response 原样返回,跳过校验/再序列化。"""
|
||
return resp.model_dump_json()
|
||
|
||
|
||
async def _cached_json_response(key: str) -> Response | None:
|
||
"""两级缓存读(进程内 → Redis):命中返回可直接吐给客户端的 Response。
|
||
存的均为序列化好的 JSON 字符串(Redis 侧 json.loads 后仍是 str),Redis 命中顺手晋级本地。"""
|
||
raw = cache.local_get(key)
|
||
if raw is None:
|
||
raw = await cache.cache_get(key)
|
||
if not isinstance(raw, str):
|
||
return None
|
||
cache.local_set(key, raw, ttl=120)
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
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,
|
||
amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None),
|
||
)
|
||
for r in rows
|
||
]
|
||
|
||
|
||
_ADJUST_MODES = ("bfq", "qfq", "hfq")
|
||
# MA 全量集合(前端已改为本地计算 MA,后端始终返回此集合以保证缓存一致)
|
||
_FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
|
||
# 指数 K 线支持的周期(日线基底聚合)
|
||
_INDEX_TIMEFRAMES = ("1d", "1w", "1M", "1y")
|
||
|
||
# 信息卡一条 SQL 拿全:stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot
|
||
# (LATERAL 单条替换原两条查询,语义不变:target 为 NULL 时全按最新日兜底)。
|
||
# ETF 走 etf_basic 分支(代码前缀与股票不重叠,两分支至多一个命中):
|
||
# 名称/上市日来自表内,市值(元)换算成万元与快照口径一致,无 PE/PB。
|
||
_INFO_SQL = text(
|
||
"""
|
||
SELECT ts_code, symbol, name, industry, area, market, list_date,
|
||
turnover_rate, pe_ttm, pb, total_mv, circ_mv
|
||
FROM (
|
||
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.area, sb.market, sb.list_date,
|
||
ds.turnover_rate, ds.pe_ttm, ds.pb, ds.total_mv, ds.circ_mv
|
||
FROM stock_basic sb
|
||
LEFT JOIN LATERAL (
|
||
SELECT turnover_rate, pe_ttm, pb, total_mv, circ_mv
|
||
FROM daily_snapshot
|
||
WHERE ts_code = sb.ts_code
|
||
ORDER BY (trade_date = cast(:target AS timestamp)) DESC, trade_date DESC
|
||
LIMIT 1
|
||
) ds ON true
|
||
WHERE sb.ts_code = :code
|
||
UNION ALL
|
||
SELECT eb.ts_code, eb.symbol, eb.name, NULL, NULL,
|
||
CASE eb.exchange WHEN 'SH' THEN '沪市' ELSE '深市' END, eb.list_date,
|
||
eb.turnover_rate, NULL, NULL,
|
||
eb.total_mv / 10000.0, eb.circ_mv / 10000.0
|
||
FROM etf_basic eb
|
||
WHERE eb.ts_code = :code
|
||
) t
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
|
||
# 复权因子是阶梯函数(除权日之间不变):只取「变化点」行,把每符号 ~7000 行日级因子压到
|
||
# 几十行(600118 仅 32 行),传输量再降两个数量级;lag 窗口在覆盖索引上走 Index Only Scan。
|
||
# upto 传全局最新交易日(非分页)或 end 日期(分页);窗口首行 prev 为 NULL 恒被保留(窗口基线因子)。
|
||
_FACTOR_STEP_SQL = text(
|
||
"""
|
||
SELECT trade_date, adj_factor FROM (
|
||
SELECT trade_date, adj_factor,
|
||
lag(adj_factor) OVER (ORDER BY trade_date) AS prev
|
||
FROM adj_factor
|
||
WHERE ts_code = :code AND trade_date <= :upto
|
||
) t
|
||
WHERE adj_factor IS DISTINCT FROM prev
|
||
ORDER BY trade_date
|
||
"""
|
||
)
|
||
|
||
|
||
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[0].date(), float(f[1])) 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,
|
||
# 成交额/换手率是名义量,不随复权缩放
|
||
amount=b.amount, turnover=b.turnover,
|
||
))
|
||
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]:
|
||
# 始终以日线为基底,再聚合到目标周期(取最新 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, amount=b.amount, turnover=b.turnover)
|
||
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))
|
||
|
||
|
||
# ---------- 股票列表(全市场浏览) ----------
|
||
# 过滤/排序/分页在 stock_basic+watchlist+daily_snapshot 上完成(快照按最新交易日
|
||
# 走唯一索引 join,便宜),再对「本页」≤limit 只股票补最新价/昨收(LATERAL 扫
|
||
# candles,贵)——旧写法对全市场 ~5000 只逐个算,每页都白算 50 倍的行情量。
|
||
# 排序列白名单(键→CTE 内表达式);order_by 由白名单拼接进模板,不接收用户原文。
|
||
_STOCKS_SORTS = {
|
||
"symbol": "sb.symbol",
|
||
"total_mv": "snap.total_mv",
|
||
"circ_mv": "snap.circ_mv",
|
||
"pe_ttm": "snap.pe_ttm",
|
||
"pb": "snap.pb",
|
||
"turnover_rate": "snap.turnover_rate",
|
||
}
|
||
|
||
_STOCKS_SQL_TMPL = """
|
||
WITH page AS (
|
||
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
|
||
(w.id IS NOT NULL) AS watched,
|
||
snap.turnover_rate, snap.pe_ttm, snap.pb, snap.total_mv, snap.circ_mv
|
||
FROM stock_basic sb
|
||
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
|
||
LEFT JOIN daily_snapshot snap ON snap.ts_code = sb.ts_code
|
||
AND snap.trade_date = (SELECT max(trade_date) FROM daily_snapshot)
|
||
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 {order_by}
|
||
LIMIT :limit OFFSET :offset
|
||
)
|
||
SELECT p.ts_code, p.symbol, p.name, p.industry, p.market, p.watched,
|
||
c.close AS close, prev.close AS prev_close, c.ts AS last_ts,
|
||
CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0
|
||
THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg,
|
||
p.turnover_rate, p.pe_ttm, p.pb,
|
||
round((p.total_mv / 10000.0)::numeric, 2) AS total_mv,
|
||
round((p.circ_mv / 10000.0)::numeric, 2) AS circ_mv
|
||
FROM page p
|
||
LEFT JOIN LATERAL (
|
||
SELECT close, ts FROM candles
|
||
WHERE symbol = p.symbol AND timeframe = '1d'
|
||
ORDER BY ts DESC LIMIT 1
|
||
) c ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT close FROM candles
|
||
WHERE symbol = p.symbol AND timeframe = '1d' AND ts < c.ts
|
||
ORDER BY ts DESC LIMIT 1
|
||
) prev ON c.ts IS NOT NULL
|
||
"""
|
||
|
||
|
||
def _stocks_sql(sort: str, order: str) -> TextClause:
|
||
col = _STOCKS_SORTS.get(sort, _STOCKS_SORTS["symbol"])
|
||
direction = "DESC" if order == "desc" else "ASC"
|
||
nulls = " NULLS LAST" if col != "sb.symbol" else "" # 快照缺失/亏损无 PE 的排最后
|
||
return text(_STOCKS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
|
||
|
||
_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,
|
||
sort: str = "symbol",
|
||
order: str = "asc",
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> Response:
|
||
"""全市场股票列表:stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标
|
||
(换手率/PE-TTM/PB/市值,无快照则这些列为空)。
|
||
watched_only=true 只看自选(自选有独立的「自选」分类入口,列表不再把自选排最前)。
|
||
sort ∈ {symbol,total_mv,circ_mv,pe_ttm,pb,turnover_rate}(白名单,其他值回落 symbol),
|
||
order ∈ asc/desc;快照列排序时缺失值(无快照/亏损无 PE)恒排末尾。
|
||
缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total);自选增删即时失效;
|
||
与 preview 同款序列化 JSON 直返(j 前缀),命中跳过 pydantic 校验/序列化。"""
|
||
search = search.strip()
|
||
sort = sort if sort in _STOCKS_SORTS else "symbol"
|
||
order = "desc" if order.lower() == "desc" else "asc"
|
||
limit = max(1, min(limit, 500))
|
||
offset = max(0, offset)
|
||
key = (
|
||
f"stocksj:u{user.id}"
|
||
f":v{await cache.get_version(f'watchlist:{user.id}')}"
|
||
f":{cache.digest(search, market, industry, area, watched_only, sort, order, limit, offset)}"
|
||
)
|
||
cached = await _cached_json_response(key)
|
||
if cached is not None:
|
||
return cached
|
||
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(sort, order), params)).mappings().all()
|
||
resp = StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
|
||
raw = _raw_json(resp)
|
||
cache.local_set(key, raw, ttl=min(120, settings.stocks_cache_ttl))
|
||
asyncio.create_task(cache.cache_set(key, raw, ttl=settings.stocks_cache_ttl))
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
@router.get("/stocks/facets", response_model=StockFacetsResponse)
|
||
async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response:
|
||
"""看股页筛选项:行业 / 地域(含数量,按数量降序)。stock_basic 很少变,长缓存。"""
|
||
cached = await _cached_json_response("facetsj:stocks")
|
||
if cached is not None:
|
||
return cached
|
||
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()
|
||
resp = 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],
|
||
)
|
||
raw = _raw_json(resp)
|
||
cache.local_set("facetsj:stocks", raw, ttl=min(120, settings.facets_cache_ttl))
|
||
asyncio.create_task(cache.cache_set("facetsj:stocks", raw, ttl=settings.facets_cache_ttl))
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
@router.get("/stocks/{ts_code}/company", response_model=StockCompanyOut)
|
||
async def stock_company_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockCompanyOut:
|
||
"""公司简介:库内有新鲜行直返;否则锁内单查 tushare(stock_company)并 upsert(行即缓存,
|
||
30 天新鲜度,无此股写墓碑负缓存)。ETF 前置短路;确认无数据 404;tushare 失败且
|
||
无旧行可降级时 503(有旧行则在数据层降级返回旧行)。"""
|
||
code = ts_code.strip().upper()
|
||
if "." not in code:
|
||
code = to_ts_code(code) # 防御:兼容 6 位裸代码
|
||
if is_etf_symbol(code):
|
||
raise HTTPException(status_code=404, detail="ETF 无公司简介")
|
||
try:
|
||
row = await company_mod.get_company(session, code)
|
||
except Exception:
|
||
raise HTTPException(status_code=503, detail="tushare 公司简介拉取失败,请稍后重试")
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail=f"无公司信息: {code}")
|
||
return StockCompanyOut(**row)
|
||
|
||
|
||
@router.get("/stocks/{ts_code}/finance", response_model=StockFinanceOut)
|
||
async def stock_finance_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockFinanceOut:
|
||
"""财务数据(近五年,按报告期倒序):库内新鲜直返;否则锁内拉 tushare 四源
|
||
(fina_indicator/income/balancesheet/cashflow)合并 upsert(7 天新鲜度,无数据写墓碑)。
|
||
ETF 前置短路;确认无数据 404;tushare 四源全失败且无旧行可降级时 503。"""
|
||
code = ts_code.strip().upper()
|
||
if "." not in code:
|
||
code = to_ts_code(code)
|
||
if is_etf_symbol(code):
|
||
raise HTTPException(status_code=404, detail="ETF 无财务数据")
|
||
try:
|
||
rows = await finance_mod.get_finance(session, code)
|
||
except Exception:
|
||
raise HTTPException(status_code=503, detail="tushare 财务数据拉取失败,请稍后重试")
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=f"无财务数据: {code}")
|
||
return StockFinanceOut(ts_code=code, records=rows)
|
||
|
||
|
||
@router.get("/stocks/{ts_code}/dividends", response_model=StockDividendOut)
|
||
async def stock_dividend_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockDividendOut:
|
||
"""分红送股(全历史,按分红年度倒序):库内新鲜直返;否则锁内拉 tushare dividend
|
||
全量替换(7 天新鲜度,无分红写墓碑,空列表是正常返回)。
|
||
ETF 前置短路;tushare 失败且无旧行可降级时 503。"""
|
||
code = ts_code.strip().upper()
|
||
if "." not in code:
|
||
code = to_ts_code(code)
|
||
if is_etf_symbol(code):
|
||
raise HTTPException(status_code=404, detail="ETF 无分红数据")
|
||
try:
|
||
rows = await dividend_mod.get_dividends(session, code)
|
||
except Exception:
|
||
raise HTTPException(status_code=503, detail="tushare 分红数据拉取失败,请稍后重试")
|
||
return StockDividendOut(ts_code=code, records=rows)
|
||
|
||
|
||
@router.get("/stocks/{ts_code}/reference/{kind}", response_model=StockReferenceOut)
|
||
async def stock_reference_info(ts_code: str, kind: str, session: AsyncSession = Depends(get_session)) -> StockReferenceOut:
|
||
"""参考数据(11 类,kind 白名单见 reference.REFERENCE_KINDS):单股单分类 JSON 快照
|
||
懒加载(7 天新鲜度,无数据写墓碑,空 records 是正常返回)。repurchase 为全市场
|
||
按月回填的特殊管道:首次触发后台回填近 24 个月(本次可能返回空,稍后再看)。
|
||
ETF 前置短路;未知 kind 404;tushare 失败且无旧行可降级时 503。"""
|
||
code = ts_code.strip().upper()
|
||
if "." not in code:
|
||
code = to_ts_code(code)
|
||
if is_etf_symbol(code):
|
||
raise HTTPException(status_code=404, detail="ETF 无参考数据")
|
||
if kind not in reference_mod.REFERENCE_KINDS:
|
||
raise HTTPException(status_code=404, detail=f"未知参考数据分类: {kind}")
|
||
try:
|
||
rows = await reference_mod.get_reference(session, code, kind)
|
||
except Exception:
|
||
raise HTTPException(status_code=503, detail="tushare 参考数据拉取失败,请稍后重试")
|
||
return StockReferenceOut(ts_code=code, kind=kind, records=rows)
|
||
|
||
|
||
# ---------- ETF 列表(全市场浏览;行情走 candles 底座,规模/换手走东财快照) ----------
|
||
# 与 /stocks 不同:成交额来自 candles 最新 bar(LATERAL),必须在分页前 join 才能参与
|
||
# 排序 —— ETF 全市场仅 ~1100 行,3 个索引探测/行 也就几 ms,可以承受。
|
||
# 排序列白名单(键→表达式);order_by 由白名单拼接进模板,不接收用户原文。
|
||
_ETFS_SORTS = {
|
||
"symbol": "eb.symbol",
|
||
"close": "c.close",
|
||
"pct_chg": "pct_chg",
|
||
"amount": "c.amount",
|
||
"total_mv": "eb.total_mv",
|
||
"circ_mv": "eb.circ_mv",
|
||
"turnover_rate": "eb.turnover_rate",
|
||
}
|
||
|
||
_ETFS_SQL_TMPL = """
|
||
SELECT eb.ts_code, eb.symbol, eb.name, eb.exchange, eb.list_date,
|
||
(w.id IS NOT NULL) AS watched,
|
||
eb.turnover_rate,
|
||
round((eb.total_mv / 100000000.0)::numeric, 2) AS total_mv,
|
||
round((eb.circ_mv / 100000000.0)::numeric, 2) AS circ_mv,
|
||
c.close AS close, prev.close AS prev_close, c.ts AS last_ts,
|
||
CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0
|
||
THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg,
|
||
round((c.amount / 100000000.0)::numeric, 2) AS amount
|
||
FROM etf_basic eb
|
||
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
|
||
LEFT JOIN LATERAL (
|
||
SELECT close, ts, amount FROM candles
|
||
WHERE symbol = eb.symbol AND timeframe = '1d'
|
||
ORDER BY ts DESC LIMIT 1
|
||
) c ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT close FROM candles
|
||
WHERE symbol = eb.symbol AND timeframe = '1d' AND ts < c.ts
|
||
ORDER BY ts DESC LIMIT 1
|
||
) prev ON c.ts IS NOT NULL
|
||
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
|
||
AND (:exchange = '' OR eb.exchange = :exchange)
|
||
AND (:watched_only = false OR w.id IS NOT NULL)
|
||
ORDER BY {order_by}
|
||
LIMIT :limit OFFSET :offset
|
||
"""
|
||
|
||
_ETFS_COUNT_SQL = text("""
|
||
SELECT count(*) FROM etf_basic eb
|
||
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
|
||
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
|
||
AND (:exchange = '' OR eb.exchange = :exchange)
|
||
AND (:watched_only = false OR w.id IS NOT NULL)
|
||
""")
|
||
|
||
|
||
def _etfs_sql(sort: str, order: str) -> TextClause:
|
||
col = _ETFS_SORTS.get(sort, _ETFS_SORTS["symbol"])
|
||
direction = "DESC" if order == "desc" else "ASC"
|
||
nulls = " NULLS LAST" if col != "eb.symbol" else "" # 无行情/无快照的排最后
|
||
return text(_ETFS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
|
||
|
||
|
||
@router.get("/etfs", response_model=EtfListResponse)
|
||
async def list_etfs(
|
||
search: str = "",
|
||
exchange: str = "",
|
||
watched_only: bool = False,
|
||
sort: str = "symbol",
|
||
order: str = "asc",
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> Response:
|
||
"""全市场场内 ETF 列表:etf_basic 名称/规模(东财快照)+ candles 最新收盘/涨跌幅/成交额。
|
||
exchange ∈ {SH, SZ}(空 = 全部);sort ∈ {symbol,close,pct_chg,amount,total_mv,circ_mv,
|
||
turnover_rate}(白名单,其他值回落 symbol),order ∈ asc/desc;快照/行情列排序时
|
||
缺失值恒排末尾。缓存:按「用户自选版本 + etf 版本 + 查询参数」缓存整页,
|
||
ETF 同步完成(bump ver:etf / ver:candles)或自选增删即失效。
|
||
"""
|
||
search = search.strip()
|
||
sort = sort if sort in _ETFS_SORTS else "symbol"
|
||
order = "desc" if order.lower() == "desc" else "asc"
|
||
limit = max(1, min(limit, 500))
|
||
offset = max(0, offset)
|
||
key = (
|
||
f"etfsj:u{user.id}"
|
||
f":v{await cache.get_version(f'watchlist:{user.id}')}"
|
||
f":v{await cache.get_version('etf')}"
|
||
f":{cache.digest(search, exchange, watched_only, sort, order, limit, offset)}"
|
||
)
|
||
cached = await _cached_json_response(key)
|
||
if cached is not None:
|
||
return cached
|
||
params = {
|
||
"search": search, "psearch": f"%{search}%",
|
||
"exchange": exchange.upper(), "watched_only": watched_only,
|
||
"uid": user.id, "limit": limit, "offset": offset,
|
||
}
|
||
total = (await session.execute(_ETFS_COUNT_SQL, params)).scalar_one()
|
||
rows = (await session.execute(_etfs_sql(sort, order), params)).mappings().all()
|
||
resp = EtfListResponse(total=total, items=[EtfListItemOut(**r) for r in rows])
|
||
raw = _raw_json(resp)
|
||
cache.local_set(key, raw, ttl=min(120, settings.stocks_cache_ttl))
|
||
asyncio.create_task(cache.cache_set(key, raw, ttl=settings.stocks_cache_ttl))
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
@router.post("/etf/sync", response_model=EtfSyncStatus)
|
||
async def etf_sync_start(req: EtfSyncRequest) -> EtfSyncStatus:
|
||
"""启动全市场 ETF 同步(后台任务:东财快照 -> etf_basic,逐只日线 -> candles)。"""
|
||
return EtfSyncStatus(**await etf_sync_mod.start_sync(full=req.full))
|
||
|
||
|
||
@router.get("/etf/sync/status", response_model=EtfSyncStatus)
|
||
async def etf_sync_status(session: AsyncSession = Depends(get_session)) -> EtfSyncStatus:
|
||
"""ETF 同步任务状态与数据实况(ETF 数 / 最新交易日)。"""
|
||
return EtfSyncStatus(**await etf_sync_mod.get_status(session))
|
||
|
||
|
||
@router.get("/market/overview", response_model=MarketOverviewResponse)
|
||
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:
|
||
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)
|
||
|
||
|
||
@router.get("/market/index-candles", response_model=list[CandleOut])
|
||
async def get_index_candles(timeframe: str = "1d") -> Response:
|
||
"""上证指数全量 K 线:日线为基底(tushare index_daily,进程内+Redis 缓存),
|
||
聚合到 1d/1w/1M/1y。收盘口径(数据随 EOD 更新,与总览 spark 一致)。"""
|
||
if timeframe not in _INDEX_TIMEFRAMES:
|
||
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(_INDEX_TIMEFRAMES)}")
|
||
key = f"idxkj:{cache.digest('idxc', SH_INDEX, timeframe)}"
|
||
cached = await _cached_json_response(key)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
bars = resample_bars(await get_index_daily(), timeframe)
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}")
|
||
outs = [
|
||
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
|
||
volume=b.volume, amount=b.amount, turnover=None)
|
||
for b in bars
|
||
]
|
||
# pydantic-core 序列化(与 _raw_json 同款),历史不可变、TTL 兜到当日更新
|
||
raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode()
|
||
cache.local_set(key, raw, ttl=300)
|
||
await cache.cache_set(key, raw, ttl=7200)
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
@router.get("/market/global-indexes", response_model=GlobalIndexListResponse)
|
||
async def get_global_indexes() -> GlobalIndexListResponse:
|
||
"""国际指数卡片列表(index_global 21 个指数最新收盘 + 45 日 spark,SWR 缓存)。"""
|
||
try:
|
||
data = await index_mod.fetch_global_list()
|
||
except index_mod.GlobalIndexError as e:
|
||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||
payload = dict(data)
|
||
payload["updated_at"] = payload.pop("fetched_at")
|
||
return GlobalIndexListResponse(**payload)
|
||
|
||
|
||
def _index_or_404(code: str) -> str:
|
||
"""详情/K线/权重接口只放行白名单内的指数 code。"""
|
||
if not index_mod.ensure_known(code):
|
||
raise HTTPException(status_code=404, detail=f"不支持的指数代码: {code}")
|
||
return code
|
||
|
||
|
||
@router.get("/market/indexes/{code}", response_model=IndexDetailResponse)
|
||
async def get_index_detail(code: str) -> IndexDetailResponse:
|
||
"""指数详情聚合:最新行情(收盘口径)+ 基本信息(国内 index_basic / 国际静态表)
|
||
+ 估值指标(index_dailybasic,仅部分国内指数)。各层自带缓存,直接组装。"""
|
||
code = _index_or_404(code)
|
||
if index_mod.is_cn_index(code):
|
||
name, region = index_mod.CN_INDEXES.get(code, code), "cn"
|
||
else:
|
||
g = index_mod.GLOBAL_META[code]
|
||
name, region = g["name"], g["region"]
|
||
|
||
try:
|
||
quote = await index_mod.fetch_index_quote(code)
|
||
except index_mod.GlobalIndexError as e:
|
||
raise HTTPException(status_code=502, detail=f"指数行情获取失败: {e}") from e
|
||
|
||
basic_raw = await index_mod.get_index_basic(code)
|
||
basic = IndexBasicOut(**basic_raw) if basic_raw else None
|
||
|
||
valuation_rows = await index_mod.get_index_valuation(code)
|
||
valuation = IndexValuationPointOut(**valuation_rows[-1]) if valuation_rows else None
|
||
history = [IndexValuationPointOut(**r) for r in valuation_rows]
|
||
|
||
return IndexDetailResponse(
|
||
code=code, name=name, region=region,
|
||
quote=IndexQuoteBriefOut(**quote),
|
||
basic=basic, valuation=valuation, valuation_history=history,
|
||
)
|
||
|
||
|
||
@router.get("/market/indexes/{code}/candles", response_model=list[CandleOut])
|
||
async def get_any_index_candles(code: str, timeframe: str = "1d") -> Response:
|
||
"""白名单指数全量 K 线(国内 index_daily / 国际 index_global),1d/1w/1M/1y 聚合。
|
||
收盘口径,历史不可变、TTL 兜到当日更新(与首页上证 K 线同款缓存策略)。"""
|
||
code = _index_or_404(code)
|
||
if timeframe not in _INDEX_TIMEFRAMES:
|
||
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(_INDEX_TIMEFRAMES)}")
|
||
key = f"idxck:{cache.digest('idxk2', code, timeframe)}"
|
||
cached = await _cached_json_response(key)
|
||
if cached is not None:
|
||
return cached
|
||
try:
|
||
bars = resample_bars(await index_mod.get_index_bars(code), timeframe)
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}")
|
||
outs = [
|
||
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
|
||
volume=b.volume, amount=b.amount, turnover=None)
|
||
for b in bars
|
||
]
|
||
raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode()
|
||
cache.local_set(key, raw, ttl=300)
|
||
await cache.cache_set(key, raw, ttl=7200)
|
||
return Response(content=raw, media_type="application/json")
|
||
|
||
|
||
@router.get("/market/indexes/{code}/weights", response_model=IndexWeightsResponse)
|
||
async def get_index_weights(
|
||
code: str,
|
||
limit: int = 50,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> IndexWeightsResponse:
|
||
"""指数成分股权重(index_weight 最近月度快照,按权重降序取前 limit)。
|
||
仅国内指数有数据;成分股名称从本地 stock_basic 回填。"""
|
||
code = _index_or_404(code)
|
||
limit = max(1, min(limit, 300))
|
||
data = await index_mod.get_index_weights(code)
|
||
if data is None:
|
||
raise HTTPException(status_code=404, detail=f"该指数暂无成分权重数据: {code}")
|
||
|
||
items = data["items"][:limit]
|
||
codes = [it["con_code"] for it in items]
|
||
names: dict[str, str] = {}
|
||
if codes:
|
||
try:
|
||
rows = await session.execute(
|
||
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.ts_code.in_(codes))
|
||
)
|
||
names = {r[0]: r[1] for r in rows.all()}
|
||
except Exception: # noqa: BLE001 —— 名称缺失不阻塞权重展示
|
||
pass
|
||
return IndexWeightsResponse(
|
||
trade_date=data["trade_date"], total=data["total"],
|
||
items=[IndexWeightItemOut(con_code=c, name=names.get(c), weight=w)
|
||
for c, w in ((it["con_code"], it["weight"]) for it in items)],
|
||
)
|
||
|
||
|
||
@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),
|
||
is_fund=is_etf_symbol(req.symbol), # ETF 免印花税/过户费
|
||
)
|
||
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"],
|
||
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 = [
|
||
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")
|
||
async def screener_run(
|
||
req: ScreenerRunRequest,
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> StreamingResponse:
|
||
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。
|
||
|
||
NDJSON 流式响应(每行一个 JSON 事件,前端逐行渲染进度):
|
||
{"type":"stage","key":"llm|date|prefilter|bars|filter_done|done","msg":"…","ms":123}
|
||
{"type":"parsed","conditions":{…},"ms":456} LLM 解析出的结构化条件
|
||
{"type":"candidates","count":5400,"msg":"…","ms":…} SQL 预筛后的候选数
|
||
{"type":"progress","done":500,"total":5400} 逐股指标过滤进度
|
||
{"type":"result","result":{…ScreenerRunResponse…},"ms":…}
|
||
{"type":"error","message":"…","code":400} 流中途失败(HTTP 已 200)
|
||
成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。
|
||
"""
|
||
limit = settings.screener_default_limit
|
||
|
||
async def gen():
|
||
try:
|
||
if req.conditions:
|
||
conds = req.conditions
|
||
else:
|
||
yield _ndjson({"type": "stage", "key": "llm",
|
||
"msg": f"AI 解析条件中({settings.llm_model})…"})
|
||
conds = await parse_conditions(req.text)
|
||
if not conds.indicator and not conds.snapshot:
|
||
yield _ndjson({"type": "error", "code": 400,
|
||
"message": "AI 未从描述中解析出任何筛选条件,请换种说法"})
|
||
return
|
||
yield _ndjson({"type": "parsed", "conditions": conds.model_dump()})
|
||
|
||
result = None
|
||
async for ev in engine.run_screen_events(session, conds, limit):
|
||
if ev["type"] == "result":
|
||
result = ev["result"]
|
||
yield _ndjson({"type": "stage", "key": "done", "ms": ev.get("ms"),
|
||
"msg": f"筛选完成:{result['total']} 只命中(数据基准 {result['trade_date']:%Y-%m-%d})"})
|
||
else:
|
||
yield _ndjson(ev)
|
||
|
||
if result is None:
|
||
yield _ndjson({"type": "error", "code": 500, "message": "选股流程未产出结果"})
|
||
return
|
||
yield _ndjson({"type": "result", "result": ScreenerRunResponse(**result).model_dump(mode="json")})
|
||
|
||
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
|
||
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()
|
||
except DataNotReadyError as e:
|
||
yield _ndjson({"type": "error", "code": 409, "message": str(e)})
|
||
except ValueError as e: # 未知指标/字段、条件为空
|
||
yield _ndjson({"type": "error", "code": 400, "message": str(e)})
|
||
except ScreenerError as e:
|
||
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
|
||
yield _ndjson({"type": "error", "code": code, "message": str(e)})
|
||
except Exception as e: # noqa: BLE001
|
||
yield _ndjson({"type": "error", "code": 500, "message": f"选股失败: {e}"})
|
||
|
||
return StreamingResponse(gen(), media_type="application/x-ndjson",
|
||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||
|
||
|
||
def _ndjson(obj: dict) -> str:
|
||
"""dict -> NDJSON 行(json.dumps 保证 default=str 兜底 datetime 等)。"""
|
||
return json.dumps(obj, ensure_ascii=False, default=str) + "\n"
|
||
|
||
|
||
@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()
|
||
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
|
||
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()
|
||
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
|
||
return await get_watchlist(session=session, user=user)
|
||
|
||
|
||
# ---------- 交割单(个人实盘买卖点) ----------
|
||
def _trade_out(r: UserTrade) -> UserTradeOut:
|
||
return UserTradeOut(
|
||
id=r.id, ts_code=r.ts_code, name=r.name, trade_date=r.trade_date,
|
||
direction=r.direction, price=r.price, qty=r.qty, amount=r.amount, fee=r.fee,
|
||
)
|
||
|
||
|
||
@router.get("/trades", response_model=list[UserTradeOut])
|
||
async def list_trades(
|
||
ts_code: str | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> list[UserTradeOut]:
|
||
"""当前用户导入的实盘成交(可选 ts_code 过滤,按日期升序;K线买卖点数据源)。"""
|
||
q = (
|
||
select(UserTrade)
|
||
.where(UserTrade.user_id == user.id)
|
||
.order_by(UserTrade.trade_date, UserTrade.id)
|
||
)
|
||
if ts_code:
|
||
q = q.where(UserTrade.ts_code == ts_code)
|
||
rows = (await session.execute(q)).scalars().all()
|
||
return [_trade_out(r) for r in rows]
|
||
|
||
|
||
@router.post("/trades/import", response_model=TradesImportResponse)
|
||
async def import_trades(
|
||
file: UploadFile = File(...),
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> TradesImportResponse:
|
||
"""上传券商交割单(CSV/Excel/HTML 表格均可,自动识别列名),解析出买卖成交入库。
|
||
|
||
同一笔成交(同日同股同向同价同量)重复上传会跳过,重复导出幂等。
|
||
"""
|
||
data = await file.read()
|
||
if not data:
|
||
raise HTTPException(status_code=422, detail="文件是空的")
|
||
if len(data) > 20 * 1024 * 1024:
|
||
raise HTTPException(status_code=413, detail="文件超过 20MB,请分时间段导出")
|
||
|
||
parsed = parse_statement(data, file.filename or "")
|
||
|
||
# 无证券代码列的导出(招商式):按证券名称反查 stock_basic 补 ts_code;同名多码或查不到则弃行
|
||
unnamed = {t.name for t in parsed.trades if not t.ts_code and t.name}
|
||
if unnamed:
|
||
name_map: dict[str, str] = {}
|
||
for ts_code, name in (await session.execute(
|
||
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.name.in_(unnamed))
|
||
)).all():
|
||
name_map[name] = "" if name in name_map else ts_code
|
||
for t in parsed.trades:
|
||
if not t.ts_code and t.name:
|
||
tc = name_map.get(t.name, "")
|
||
if tc:
|
||
t.ts_code, t.code = tc, tc.split(".")[0]
|
||
else:
|
||
parsed.skipped_bad.append(f"{t.trade_date} {t.name} 名称无法唯一对应代码,未入库")
|
||
|
||
def _key(t) -> tuple:
|
||
return (t.trade_date, t.ts_code, t.direction, None if t.price is None else round(t.price, 4), round(t.qty, 4))
|
||
|
||
# Python 侧去重兜底(唯一约束对 NULL price 不生效)
|
||
existing = {
|
||
(r.trade_date, r.ts_code, r.direction, None if r.price is None else round(r.price, 4), round(r.qty, 4))
|
||
for r in (
|
||
await session.execute(
|
||
select(UserTrade.trade_date, UserTrade.ts_code, UserTrade.direction, UserTrade.price, UserTrade.qty)
|
||
.where(UserTrade.user_id == user.id, UserTrade.ts_code.in_({t.ts_code for t in parsed.trades}))
|
||
)
|
||
).all()
|
||
}
|
||
inserted: list[UserTrade] = []
|
||
seen: set[tuple] = set()
|
||
skipped_dup = 0
|
||
for t in parsed.trades:
|
||
if not t.ts_code:
|
||
continue # 名称反查失败的行,已在 bad 里说明
|
||
k = _key(t)
|
||
if k in existing or k in seen:
|
||
skipped_dup += 1
|
||
continue
|
||
seen.add(k)
|
||
inserted.append(UserTrade(
|
||
user_id=user.id, ts_code=t.ts_code, code=t.code, name=t.name or None,
|
||
trade_date=t.trade_date, direction=t.direction, price=t.price,
|
||
qty=t.qty, amount=t.amount, fee=t.fee,
|
||
raw_json=json.dumps(t.raw, ensure_ascii=False, default=str),
|
||
))
|
||
if inserted:
|
||
session.add_all(inserted)
|
||
await session.commit()
|
||
|
||
return TradesImportResponse(
|
||
inserted=len(inserted),
|
||
skipped_dup=skipped_dup,
|
||
skipped_other=parsed.skipped_other,
|
||
stocks=len({t.ts_code for t in parsed.trades}),
|
||
bad=parsed.skipped_bad[:5],
|
||
sample=[_trade_out(r) for r in inserted[:5]],
|
||
)
|
||
|
||
|
||
@router.delete("/trades", response_model=TradesClearResponse)
|
||
async def clear_trades(
|
||
session: AsyncSession = Depends(get_session),
|
||
user=Depends(require_user),
|
||
) -> TradesClearResponse:
|
||
"""清空当前用户导入的全部成交(重新导入前用)。"""
|
||
res = await session.execute(delete(UserTrade).where(UserTrade.user_id == user.id))
|
||
await session.commit()
|
||
return TradesClearResponse(deleted=res.rowcount or 0)
|
||
|
||
|
||
@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",
|
||
zx: str = "10,20,30,60", end: str | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Response:
|
||
"""个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq,
|
||
未缓存自动拉取,落后全市场最新交易日则强制刷新)+ 全套指标 + 最新截面信息卡。
|
||
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"):
|
||
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]
|
||
try:
|
||
zx_periods = sorted({int(p) for p in zx.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="zx 格式应为逗号分隔的数字,如 10,20,30,60")
|
||
if not zx_periods:
|
||
zx_periods = [10, 20, 30, 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)
|
||
|
||
# --- 两级读缓存:历史窗口(end 翻页)只增不改,最新窗口每日由全市场同步推进;
|
||
# 键含 ver:candles 版本号(同步完成后自增,旧缓存全部失效),TTL 兜底(cache.py)。
|
||
# 存序列化好的 JSON 直返(j: 前缀),跳过 json.loads + pydantic 校验/序列化(热路径数百 ms → 个位数)。
|
||
# 注:ma_periods 不参与缓存键 —— 前端已改为本地计算 MA,后端始终返回全量 MA 集合
|
||
cache_key = cache.digest(
|
||
"preview", ts_code, timeframe, limit, adjust,
|
||
end_dt.strftime("%Y-%m-%d") if end_dt else None,
|
||
await cache.get_version("candles"),
|
||
)
|
||
cached = await _cached_json_response(f"pvj:{cache_key}")
|
||
if cached is not None:
|
||
return cached
|
||
|
||
# --- 日线:candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
|
||
# fetcher 只做「不复权」增量 upsert,底座口径恒为 bfq(TDX 全量 + Tushare 增量),
|
||
# 复权(qfq/hfq)读取时按 adj_factor 表本地换算。
|
||
# 每次只取「窗口 + 400 根预热」行(MA250/MACD EMA 在 400 根内充分收敛),不拉全量:
|
||
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
|
||
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
|
||
fetch_n = min(100000, limit * frame_mult + 400)
|
||
source = "bfq"
|
||
mode = "bfq"
|
||
# 并发约定:注入 session 与 s2 各占一条连接,每次 gather 里每个 session 恰好跑一条查询
|
||
# (AsyncSession 单连接非并发安全),把 ~6 次串行 DB RTT 折叠成 2 个波次。
|
||
async with async_session() as s2:
|
||
if end_dt is not None:
|
||
# 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览);max(ts) 用不到
|
||
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
|
||
global_latest = None
|
||
else:
|
||
# Wave 1:candles 窗口(注入 session)+ 全市场最新交易日(s2)并行
|
||
rows, global_latest = await asyncio.gather(
|
||
repository.get_recent_candles(session, symbol, "1d", limit=fetch_n),
|
||
s2.scalar(select(func.max(Candle.ts)).where(Candle.timeframe == "1d")),
|
||
)
|
||
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 global_latest is not None and rows[-1].ts.date() < global_latest.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 and end_dt is None:
|
||
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
|
||
# 信息卡取未聚合的日线最新 bar(聚合后 ts 是周期起点,不适用于「最新交易日」)
|
||
last_daily = bars[-1] if bars else None
|
||
prev_daily = bars[-2] if len(bars) > 1 else None
|
||
# 翻页到底(end 之前无数据):返回空页 + has_more=False,前端停止向前翻页
|
||
|
||
# --- Wave 2:复权因子(s2,覆盖索引 Index Only Scan)+ 信息卡(注入 session,LATERAL 一条)并行 ---
|
||
async def _fetch_factors() -> list | None:
|
||
if adjust == mode or not bars:
|
||
return None
|
||
# 只取因子「变化点」行(覆盖索引 Index Only Scan,免堆访问——adj_factor 堆碎片化
|
||
# 严重);bisect 在阶梯函数上取值与日级序列逐字节一致
|
||
if end_dt is not None:
|
||
# 分页:窗口 ≤ end 的变化点 + 全局最新因子(qfq 以最新因子归一)
|
||
win = list((await s2.execute(_FACTOR_STEP_SQL, {"code": ts_code, "upto": end_dt})).all())
|
||
if win:
|
||
latest_f = (await s2.execute(
|
||
select(AdjFactor.trade_date, AdjFactor.adj_factor)
|
||
.where(AdjFactor.ts_code == ts_code)
|
||
.order_by(AdjFactor.trade_date.desc()).limit(1)
|
||
)).first()
|
||
if latest_f is not None:
|
||
win.append(latest_f)
|
||
return win or None
|
||
# 非分页:上界 global_latest(≥ 最新 bar),末项变化点即全局最新因子,比
|
||
# 「窗口 ≤ bars[-1].ts + 单独 latest」少一次查询
|
||
return list((await s2.execute(
|
||
_FACTOR_STEP_SQL, {"code": ts_code, "upto": global_latest}
|
||
)).all()) or None
|
||
|
||
factors, info_row = await asyncio.gather(
|
||
_fetch_factors(),
|
||
session.execute(_INFO_SQL, {"code": ts_code, "target": last_daily.ts if last_daily else None}),
|
||
)
|
||
|
||
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
|
||
if factors:
|
||
bars = _adjust_bars(bars, factors, mode, adjust)
|
||
mode = adjust
|
||
source = adjust
|
||
|
||
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
|
||
bars = resample_bars(bars, timeframe)
|
||
|
||
# --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过) ---
|
||
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 始终返回全量集合(前端本地计算 MA,此处仅保留兼容;缓存键不依赖 ma_periods)
|
||
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in _FULL_MA_SET},
|
||
"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")},
|
||
"zx": {
|
||
"short": _series_to_jsonable(ind.ema2(closes)),
|
||
"duokong": _series_to_jsonable(ind.avg_ma(closes, tuple(zx_periods))),
|
||
},
|
||
}
|
||
limit = max(30, min(limit, len(bars)))
|
||
for group in indicators.values():
|
||
for key in group:
|
||
group[key] = group[key][-limit:]
|
||
|
||
# --- 信息卡:Wave 2 已并行取回(stock_basic + 与行情同日对齐的快照、缺则最新日,见 _INFO_SQL) ---
|
||
row = info_row.first()
|
||
if row is not None:
|
||
m = row._mapping
|
||
sb_name, sb_industry, sb_area, sb_market, sb_list_date = (
|
||
m["name"], m["industry"], m["area"], m["market"], m["list_date"]
|
||
)
|
||
ds_turnover, ds_pe, ds_pb, ds_tmv, ds_cmv = (
|
||
m["turnover_rate"], m["pe_ttm"], m["pb"], m["total_mv"], m["circ_mv"]
|
||
)
|
||
else:
|
||
sb_name = sb_industry = sb_area = sb_market = sb_list_date = None
|
||
ds_turnover = ds_pe = ds_pb = ds_tmv = ds_cmv = None
|
||
|
||
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 or ts_code,
|
||
industry=sb_industry,
|
||
area=sb_area,
|
||
market=sb_market,
|
||
list_date=sb_list_date,
|
||
trade_date=last_daily.ts if last_daily else None,
|
||
open=last_daily.open if last_daily else None,
|
||
high=last_daily.high if last_daily else None,
|
||
low=last_daily.low if last_daily else None,
|
||
close=last_daily.close if last_daily else None,
|
||
pre_close=prev_daily.close if prev_daily else None,
|
||
pct_chg=((last_daily.close / prev_daily.close - 1) * 100)
|
||
if last_daily and prev_daily and prev_daily.close else None,
|
||
volume_hand=round(last_daily.volume / 100, 0) if last_daily else None, # 股 -> 手
|
||
amount_yi=round(last_daily.amount / 1e8, 2) if last_daily and last_daily.amount else None, # 元 -> 亿元
|
||
turnover_rate=ds_turnover,
|
||
pe_ttm=ds_pe,
|
||
pb=ds_pb,
|
||
total_mv=_yi(ds_tmv),
|
||
circ_mv=_yi(ds_cmv),
|
||
)
|
||
|
||
candles = [
|
||
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:]
|
||
]
|
||
resp = PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info,
|
||
candles=candles, indicators=indicators, has_more=has_more)
|
||
# 只序列化一次:本地(同步,120s)+ Redis(后台写,600s TTL 兜底跨进程/重启)
|
||
raw = _raw_json(resp)
|
||
cache.local_set(f"pvj:{cache_key}", raw, ttl=120)
|
||
asyncio.create_task(cache.cache_set(f"pvj:{cache_key}", raw, ttl=600))
|
||
return Response(content=raw, media_type="application/json")
|