看股功能更新
This commit is contained in:
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -86,7 +87,13 @@ def _series_to_jsonable(s: pd.Series) -> list[float | None]:
|
||||
|
||||
|
||||
def _rows_to_bars(rows) -> list[Bar]:
|
||||
return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows]
|
||||
return [
|
||||
Bar(
|
||||
ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume,
|
||||
amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
_ADJUST_MODES = ("bfq", "qfq", "hfq")
|
||||
@@ -120,6 +127,8 @@ def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list
|
||||
open=round(b.open * m, 3), high=round(b.high * m, 3),
|
||||
low=round(b.low * m, 3), close=round(b.close * m, 3),
|
||||
volume=b.volume,
|
||||
# 成交额/换手率是名义量,不随复权缩放
|
||||
amount=b.amount, turnover=b.turnover,
|
||||
))
|
||||
return out
|
||||
|
||||
@@ -131,10 +140,14 @@ async def get_candles(
|
||||
limit: int = 5000,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CandleOut]:
|
||||
# 始终以日线为基底,再聚合到目标周期
|
||||
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
|
||||
# 始终以日线为基底,再聚合到目标周期(取最新 limit 根)
|
||||
rows = await repository.get_recent_candles(session, symbol, "1d", limit=limit)
|
||||
bars = resample_bars(_rows_to_bars(rows), timeframe)
|
||||
return [CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars]
|
||||
return [
|
||||
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
|
||||
volume=b.volume, amount=b.amount, turnover=b.turnover)
|
||||
for b in bars
|
||||
]
|
||||
|
||||
|
||||
@router.post("/data/sync", response_model=SyncResponse)
|
||||
@@ -305,7 +318,9 @@ async def backtest(
|
||||
|
||||
candles = [
|
||||
CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"],
|
||||
close=r["close"], volume=r["volume"])
|
||||
close=r["close"], volume=r["volume"],
|
||||
amount=r["amount"] if "amount" in df.columns else None,
|
||||
turnover=r["turnover"] if "turnover" in df.columns else None)
|
||||
for _, r in df.iterrows()
|
||||
]
|
||||
signals = [
|
||||
@@ -576,11 +591,14 @@ async def screener_sync_status(session: AsyncSession = Depends(get_session)) ->
|
||||
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
|
||||
async def screener_preview(
|
||||
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
|
||||
end: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PreviewResponse:
|
||||
"""个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq,
|
||||
未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。
|
||||
timeframe 聚合到周/月/年(先复权再聚合);mas 指定主图 MA 周期(逗号分隔)。"""
|
||||
timeframe 聚合到周/月/年(先复权再聚合);mas 指定主图 MA 周期(逗号分隔)。
|
||||
end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标),
|
||||
has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。"""
|
||||
if adjust not in _ADJUST_MODES:
|
||||
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}")
|
||||
if timeframe not in ("1d", "1w", "1M", "1y"):
|
||||
@@ -591,6 +609,13 @@ async def screener_preview(
|
||||
raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60")
|
||||
if not ma_periods:
|
||||
ma_periods = [5, 10, 20, 60]
|
||||
limit = max(30, min(limit, 5000))
|
||||
end_dt: datetime | None = None
|
||||
if end:
|
||||
try:
|
||||
end_dt = datetime.strptime(end.strip()[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
|
||||
symbol = plain_code(ts_code)
|
||||
|
||||
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
|
||||
@@ -601,36 +626,48 @@ async def screener_preview(
|
||||
).scalars().first()
|
||||
|
||||
# --- 日线:candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
|
||||
# fetcher 增量拉取写入的是 qfq(settings.data_adjust),此时底座模式记为 qfq。
|
||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
||||
# fetcher 现在只做「不复权」增量 upsert,底座口径恒为 bfq(TDX 全量 + Tushare 增量),
|
||||
# 复权(qfq/hfq)读取时按 adj_factor 表本地换算,mode 无需再推断。
|
||||
# 每次只取「窗口 + 800 根预热」行(MA250/MACD EMA 在 800 根内充分收敛),不拉全量:
|
||||
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
|
||||
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
|
||||
fetch_n = min(100000, limit * frame_mult + 800)
|
||||
source = "bfq"
|
||||
mode = "bfq"
|
||||
try:
|
||||
if not rows:
|
||||
await fetcher.sync_symbol(session, symbol, source="auto")
|
||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
||||
mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq"
|
||||
elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date():
|
||||
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
|
||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
||||
mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq"
|
||||
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500)
|
||||
await session.rollback()
|
||||
if not rows:
|
||||
rows = []
|
||||
if end_dt is not None:
|
||||
# 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览)
|
||||
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
|
||||
else:
|
||||
# 注意取「最新 fetch_n 根」而非最旧:get_candles 是 asc+limit(取最旧),窗口化后首屏会停在过期日期
|
||||
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
|
||||
try:
|
||||
if not rows:
|
||||
await fetcher.sync_symbol(session, symbol, source="auto")
|
||||
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
|
||||
elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date():
|
||||
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
|
||||
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
|
||||
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500)
|
||||
await session.rollback()
|
||||
if not rows:
|
||||
rows = []
|
||||
bars = _rows_to_bars(rows)
|
||||
|
||||
if not bars:
|
||||
if not bars and end_dt is None:
|
||||
source = "market"
|
||||
res = await session.execute(
|
||||
select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date)
|
||||
)
|
||||
bars = [
|
||||
Bar(ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.vol * 100.0)
|
||||
Bar(
|
||||
ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close,
|
||||
volume=r.vol * 100.0, amount=r.amount * 1000.0 if r.amount else None, # 千元 -> 元
|
||||
)
|
||||
for r in res.scalars()
|
||||
]
|
||||
if not bars:
|
||||
if not bars and end_dt is None:
|
||||
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
|
||||
# 翻页到底(end 之前无数据):返回空页 + has_more=False,前端停止向前翻页
|
||||
|
||||
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
|
||||
if adjust != mode:
|
||||
@@ -648,27 +685,30 @@ async def screener_preview(
|
||||
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
|
||||
bars = resample_bars(bars, timeframe)
|
||||
|
||||
# --- 指标(在全量历史上计算后截尾,保证预热正确) ---
|
||||
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
|
||||
closes, highs, lows = df["close"], df["high"], df["low"]
|
||||
macd = ind.macd(closes)
|
||||
kdj = ind.kdj(highs, lows, closes)
|
||||
boll = ind.bollinger(closes)
|
||||
indicators: dict[str, dict[str, list[float | None]]] = {
|
||||
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods},
|
||||
"macd": {
|
||||
"dif": _series_to_jsonable(macd["macd"]),
|
||||
"dea": _series_to_jsonable(macd["signal"]),
|
||||
"hist": _series_to_jsonable(macd["hist"]),
|
||||
},
|
||||
"kdj": {k: _series_to_jsonable(kdj[k]) for k in ("k", "d", "j")},
|
||||
"rsi": {
|
||||
"rsi6": _series_to_jsonable(ind.rsi(closes, 6)),
|
||||
"rsi12": _series_to_jsonable(ind.rsi(closes, 12)),
|
||||
"rsi24": _series_to_jsonable(ind.rsi(closes, 24)),
|
||||
},
|
||||
"boll": {k: _series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")},
|
||||
}
|
||||
# --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过) ---
|
||||
has_more = len(bars) > limit # 返回窗口之前还有更早历史(含预热行)
|
||||
indicators: dict[str, dict[str, list[float | None]]] = {}
|
||||
if bars:
|
||||
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
|
||||
closes, highs, lows = df["close"], df["high"], df["low"]
|
||||
macd = ind.macd(closes)
|
||||
kdj = ind.kdj(highs, lows, closes)
|
||||
boll = ind.bollinger(closes)
|
||||
indicators = {
|
||||
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods},
|
||||
"macd": {
|
||||
"dif": _series_to_jsonable(macd["macd"]),
|
||||
"dea": _series_to_jsonable(macd["signal"]),
|
||||
"hist": _series_to_jsonable(macd["hist"]),
|
||||
},
|
||||
"kdj": {k: _series_to_jsonable(kdj[k]) for k in ("k", "d", "j")},
|
||||
"rsi": {
|
||||
"rsi6": _series_to_jsonable(ind.rsi(closes, 6)),
|
||||
"rsi12": _series_to_jsonable(ind.rsi(closes, 12)),
|
||||
"rsi24": _series_to_jsonable(ind.rsi(closes, 24)),
|
||||
},
|
||||
"boll": {k: _series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")},
|
||||
}
|
||||
limit = max(30, min(limit, len(bars)))
|
||||
for group in indicators.values():
|
||||
for key in group:
|
||||
@@ -724,7 +764,8 @@ async def screener_preview(
|
||||
)
|
||||
|
||||
candles = [
|
||||
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume)
|
||||
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
|
||||
volume=b.volume, amount=b.amount, turnover=b.turnover)
|
||||
for b in bars[-limit:]
|
||||
]
|
||||
return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators)
|
||||
return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators, has_more=has_more)
|
||||
|
||||
Reference in New Issue
Block a user