看股功能更新

This commit is contained in:
2026-08-15 15:36:11 +08:00
parent c1c43d2ff7
commit 9cce670b74
26 changed files with 957 additions and 427 deletions

View File

@@ -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 增量拉取写入的是 qfqsettings.data_adjust此时底座模式记qfq
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
# fetcher 现在只做「不复权」增量 upsert底座口径恒bfqTDX 全量 + 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)

View File

@@ -4,6 +4,7 @@
MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。
OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。
成交额/换手率为名义量:求和(全缺则保持 None不伪造 0
"""
from __future__ import annotations
@@ -22,6 +23,13 @@ def bars_per_year(timeframe: str) -> int:
return _BARS_PER_YEAR.get(timeframe, 252)
def _sum_or_none(s: pd.Series):
"""求和;全为 NaN 返回 None部分缺失则忽略缺失项求和"""
if s.isna().all():
return None
return float(s.sum())
def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
"""把日线 bars 聚合为目标周期;日线或未知周期原样返回。"""
if not bars or timeframe in ("1d", "d", "day", "", None):
@@ -31,14 +39,18 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
return bars
df = pd.DataFrame(
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close,
"volume": b.volume,
"amount": b.amount if b.amount is not None else float("nan"),
"turnover": b.turnover if b.turnover is not None else float("nan")}
for b in bars]
).set_index("ts").sort_index()
agg = (
df.resample(rule)
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna()
.agg({"open": "first", "high": "max", "low": "min", "close": "last",
"volume": "sum", "amount": _sum_or_none, "turnover": _sum_or_none})
.dropna(subset=["open"])
)
return [
@@ -49,6 +61,8 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
low=float(row["low"]),
close=float(row["close"]),
volume=float(row["volume"]),
amount=row["amount"] if row["amount"] == row["amount"] else None, # NaN -> None
turnover=row["turnover"] if row["turnover"] == row["turnover"] else None,
)
for ts, row in agg.iterrows()
]

View File

@@ -27,12 +27,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
bars: list[Bar] = []
for _, r in df.iterrows():
amt = r.get("成交额")
bars.append(
Bar(
ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"),
open=float(r["开盘"]), high=float(r["最高"]),
low=float(r["最低"]), close=float(r["收盘"]),
volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股
amount=float(amt) if amt is not None and amt == amt else None, # AKShare 成交额单位为元
)
)
return bars

View File

@@ -1,12 +1,15 @@
"""数据编排拉取Tushare 主 -> AKShare 兜底)+ 本地缓存。
真实行情落库到 candles 表timeframe='1d'),回测统一从库读。
真实行情落库到 candles 表timeframe='1d'**不复权底座**),回测统一从库读。
复权qfq/hfq在读取时按 adj_factor 表本地换算,见 api._adjust_bars。
"""
from __future__ import annotations
import asyncio
from sqlalchemy import delete, func, select
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
@@ -40,6 +43,13 @@ async def is_cached(session: AsyncSession, symbol: str) -> bool:
return await count_cached(session, symbol) > 0
async def _last_cached_ts(session: AsyncSession, symbol: str):
res = await session.execute(
select(func.max(Candle.ts)).where(Candle.symbol == symbol, Candle.timeframe == "1d")
)
return res.scalar()
async def sync_symbol(
session: AsyncSession,
code: str,
@@ -48,34 +58,57 @@ async def sync_symbol(
source: str = "auto",
force: bool = False,
) -> dict:
"""拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。"""
if not force and await is_cached(session, code):
"""增量拉取并 upsert 某标的日线(**不复权**底座)。
- 永不删除已有行:按 (symbol, timeframe, ts) 主键 upsert
不会把 TDX 导入的 30 年历史冲掉;
- 已缓存时从最后一根的次日开始增量拉取force 仅跳过「有缓存就返回」
的短路,用于缓存落后于最新交易日时的刷新);
- 拉不到新行时保持原缓存不动。
"""
last_ts = await _last_cached_ts(session, code)
if last_ts is not None and not force and not start:
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
if last_ts is not None and not start:
# 增量:从缓存最后一根当天开始(重叠一天重新拉取,容忍数据源漏行/盘后修订)
start = last_ts.strftime("%Y%m%d")
start = start or DEFAULT_START
adjust = settings.data_adjust
errors: list[str] = []
bars: list[Bar] = []
used = None
for name, fn in _providers(source):
try:
# tushare/akshare 是同步网络 IO丢到线程池避免阻塞事件循环
bars = await asyncio.to_thread(fn, code, start, end, adjust)
# tushare/akshare 是同步网络 IO丢到线程池避免阻塞事件循环
# adjust=None -> 不复权(复权在读取时按 adj_factor 换算)
bars = await asyncio.to_thread(fn, code, start, end, None)
used = name
break
except Exception as e: # noqa: BLE001
errors.append(f"{name}: {e}")
if not bars:
if last_ts is not None:
# 增量失败(如停牌/新股无新行):保留缓存,不算错误
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源")
# 全量替换该标的日线(避免重复主键
await session.execute(delete(Candle).where(Candle.symbol == code, Candle.timeframe == "1d"))
for b in bars:
session.add(
Candle(symbol=code, timeframe="1d", ts=b.ts, open=b.open, high=b.high,
low=b.low, close=b.close, volume=b.volume)
)
# upsert不 delete避免破坏既有底座TDX 全量历史
stmt = pg_insert(Candle).values([
{"symbol": code, "timeframe": "1d", "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
])
stmt = stmt.on_conflict_do_update(
index_elements=["symbol", "timeframe", "ts"],
set_={"open": stmt.excluded.open, "high": stmt.excluded.high, "low": stmt.excluded.low,
"close": stmt.excluded.close, "volume": stmt.excluded.volume,
# 增量源缺失额/换手时保留库里的旧值(如 TDX 已回补的 30 年成交额)
"amount": func.coalesce(stmt.excluded.amount, Candle.amount),
"turnover": func.coalesce(stmt.excluded.turnover, Candle.turnover)},
)
await session.execute(stmt)
await session.commit()
return {"symbol": code, "bars": len(bars), "source": used}

View File

@@ -32,3 +32,38 @@ async def get_candles(
stmt = stmt.order_by(Candle.ts.asc()).limit(limit)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_recent_candles(
session: AsyncSession,
symbol: str,
timeframe: str = "1d",
limit: int = 5000,
) -> list[Candle]:
"""取最近 limit 根 K 线(含最新交易日),按时间升序返回。"""
stmt = (
select(Candle)
.where(Candle.symbol == symbol, Candle.timeframe == timeframe)
.order_by(Candle.ts.desc())
.limit(limit)
)
result = await session.execute(stmt)
return list(reversed(result.scalars().all()))
async def get_candles_before(
session: AsyncSession,
symbol: str,
timeframe: str,
before: datetime,
limit: int = 5000,
) -> list[Candle]:
"""取 before 之前(不含)的最近 limit 根 K 线,按时间升序返回(历史向前翻页用)。"""
stmt = (
select(Candle)
.where(Candle.symbol == symbol, Candle.timeframe == timeframe, Candle.ts < before)
.order_by(Candle.ts.desc())
.limit(limit)
)
result = await session.execute(stmt)
return list(reversed(result.scalars().all()))

View File

@@ -40,12 +40,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
df = df.sort_values("trade_date")
bars: list[Bar] = []
for _, r in df.iterrows():
amt = r.get("amount")
bars.append(
Bar(
ts=_parse(r["trade_date"]),
open=float(r["open"]), high=float(r["high"]),
low=float(r["low"]), close=float(r["close"]),
volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股
amount=float(amt) * 1000.0 if amt is not None and amt == amt else None, # 千元 -> 元
)
)
return bars

View File

@@ -30,13 +30,19 @@ class Timeframe(str, Enum):
@dataclass(frozen=True)
class Bar:
"""一根 K 线OHLCV + 时间戳)。复权标识后续扩展。"""
"""一根 K 线OHLCV + 时间戳)。复权标识后续扩展。
amount成交额与 turnover换手率 %)是名义量,
不随复权换算缩放;周期聚合时求和。缺数据为 None。
"""
ts: datetime
open: float
high: float
low: float
close: float
volume: float
amount: float | None = None
turnover: float | None = None
@dataclass(frozen=True)

View File

@@ -33,6 +33,8 @@ class Candle(Base):
low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
volume: Mapped[float] = mapped_column(Float)
amount: Mapped[float | None] = mapped_column(Float) # 成交额TDX 原生 float32
turnover: Mapped[float | None] = mapped_column(Float) # 换手率 %daily_basic2000 年起)
__table_args__ = (
UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"),

View File

@@ -18,6 +18,8 @@ class CandleOut(BaseModel):
low: float
close: float
volume: float
amount: float | None = None # 成交额(元);无数据为 null
turnover: float | None = None # 换手率 %;无数据为 null
model_config = {"from_attributes": True}
@@ -264,6 +266,7 @@ class PreviewResponse(BaseModel):
candles: list[CandleOut]
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
# indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}}
has_more: bool = False # 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
# ---------- Auth ----------