看股功能更新
This commit is contained in:
@@ -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,底座口径恒为 bfq(TDX 全量 + Tushare 增量),
|
||||
# 复权(qfq/hfq)读取时按 adj_factor 表本地换算,mode 无需再推断。
|
||||
# 每次只取「窗口 + 800 根预热」行(MA250/MACD EMA 在 800 根内充分收敛),不拉全量:
|
||||
# --- 日线:candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
|
||||
# fetcher 只做「不复权」增量 upsert,底座口径恒为 bfq(TDX 全量 + 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
|
||||
|
||||
Reference in New Issue
Block a user