看股功能更新

This commit is contained in:
2026-08-16 20:20:59 +08:00
parent 63f8ed8b69
commit 65d2f54d1b
9 changed files with 296 additions and 107 deletions

View File

@@ -17,7 +17,7 @@ from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import delete, select, text
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import TextClause
@@ -38,8 +38,8 @@ from .trades import parse_statement
from .models import (
AdjFactor,
BacktestRun,
Candle,
DailySnapshot,
MarketDaily,
ScreenerQuery,
StockBasic,
UserPreference,
@@ -780,11 +780,11 @@ 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,
zx: str = "10,20,30,60", end: str | None = None,
session: AsyncSession = Depends(get_session),
) -> PreviewResponse:
"""个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,失败退 market_daily 近段+ 全套指标 + 最新截面信息卡。
未缓存自动拉取,落后全市场最新交易日则强制刷新+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。
end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标),
has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。"""
@@ -798,6 +798,12 @@ 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]
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:
@@ -807,20 +813,27 @@ async def screener_preview(
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
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()
# --- Redis 读缓存历史窗口end 翻页)只增不改,最新窗口每日由全市场同步推进;
# 键含 ver:candles 版本号同步完成后自增旧缓存全部失效TTL 兜底cache.py---
cache_key = cache.digest(
"preview", ts_code, timeframe, limit, adjust,
end_dt.strftime("%Y-%m-%d") if end_dt else None, ma_periods, zx_periods,
await cache.get_version("candles"),
)
cached = await cache.cache_get(f"pv:{cache_key}")
if cached is not None:
return PreviewResponse.model_validate(cached)
# --- 日线candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
# fetcher 现在只做「不复权」增量 upsert底座口径恒为 bfqTDX 全量 + Tushare 增量),
# 复权qfq/hfq读取时按 adj_factor 表本地换算mode 无需再推断
# 每次只取「窗口 + 800 根预热」行MA250/MACD EMA 在 800 根内充分收敛),不拉全量:
# --- 日线candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
# fetcher 只做「不复权」增量 upsert底座口径恒为 bfqTDX 全量 + Tushare 增量),
# 复权qfq/hfq读取时按 adj_factor 表本地换算。
# 每次只取「窗口 + 400 根预热」行MA250/MACD EMA 在 400 根内充分收敛),不拉全量:
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
global_latest = await session.scalar(
select(func.max(Candle.ts)).where(Candle.timeframe == "1d")
)
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
fetch_n = min(100000, limit * frame_mult + 800)
fetch_n = min(100000, limit * frame_mult + 400)
source = "bfq"
mode = "bfq"
if end_dt is not None:
@@ -833,7 +846,7 @@ async def screener_preview(
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():
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
@@ -842,34 +855,33 @@ async def screener_preview(
rows = []
bars = _rows_to_bars(rows)
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, amount=r.amount * 1000.0 if r.amount else None, # 千元 -> 元
)
for r in res.scalars()
]
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前端停止向前翻页
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
if adjust != mode:
factors = (
await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code).order_by(AdjFactor.trade_date)
)
).scalars().all()
# 只取窗口内因子qfq 归一还需全局最新因子追加到最后一行即可_adjust_bars 取 f_latest=末项)
if adjust != mode and bars:
fq = select(AdjFactor).where(AdjFactor.ts_code == ts_code)
window_end = end_dt if end_dt is not None else bars[-1].ts
if window_end is not None:
fq = fq.where(AdjFactor.trade_date <= window_end)
factors = list((await session.execute(fq.order_by(AdjFactor.trade_date))).scalars().all())
if factors:
latest_f = (
await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)
).scalars().first()
if latest_f is not None:
factors.append(latest_f)
bars = _adjust_bars(bars, factors, mode, adjust)
mode = adjust
if source != "market":
source = adjust
source = adjust
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
bars = resample_bars(bars, timeframe)
@@ -897,21 +909,25 @@ async def screener_preview(
"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:]
# --- 信息卡stock_basic + 最新 market_daily + 与其对齐的快照(避免混用不同交易日) ---
# --- 信息卡stock_basic + candles 最新日线 bar + 与其对齐的快照(避免混用不同交易日) ---
sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first()
ds = None
if md is not None:
if last_daily is not None:
# 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见)
ds = (
await session.execute(
select(DailySnapshot).where(
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == md.trade_date
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == last_daily.ts
)
)
).scalars().first()
@@ -936,15 +952,16 @@ async def screener_preview(
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, # 千元 -> 亿元
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_rate if ds else None,
pe_ttm=ds.pe_ttm if ds else None,
pb=ds.pb if ds else None,
@@ -957,4 +974,7 @@ async def screener_preview(
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, has_more=has_more)
resp = PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info,
candles=candles, indicators=indicators, has_more=has_more)
await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600)
return resp