This commit is contained in:
2026-08-14 17:34:26 +08:00
parent a9bf369a41
commit 0f8b9a7255
4 changed files with 172 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock
TUSHARE_TOKEN=d0bc5620d6523ae40f379ed4415576f58dca2361f2f47a68cdcd0a98 TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
DATA_ADJUST=qfq DATA_ADJUST=qfq
DATA_DEFAULT_START=20200101 DATA_DEFAULT_START=20200101

View File

@@ -13,6 +13,7 @@ import json
import pandas as pd import pandas as pd
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from .backtest.engine import BacktestConfig, run_backtest from .backtest.engine import BacktestConfig, run_backtest
@@ -20,10 +21,12 @@ from .backtest.strategies import build_strategy
from .config import settings from .config import settings
from .data import fetcher, repository from .data import fetcher, repository
from .data.aggregation import bars_per_year, resample_bars from .data.aggregation import bars_per_year, resample_bars
from .data.symbols import plain_code
from .data.synthetic import seed_if_empty from .data.synthetic import seed_if_empty
from .db import get_session from .db import get_session
from .domain import Bar from .domain import Bar
from .models import BacktestRun from . import indicators as ind
from .models import BacktestRun, DailySnapshot, MarketDaily, StockBasic
from .schemas import ( from .schemas import (
BacktestRequest, BacktestRequest,
BacktestResponse, BacktestResponse,
@@ -31,6 +34,8 @@ from .schemas import (
EquityPoint, EquityPoint,
IndicatorOut, IndicatorOut,
MetricsOut, MetricsOut,
PreviewInfoOut,
PreviewResponse,
ScreenerRunRequest, ScreenerRunRequest,
ScreenerRunResponse, ScreenerRunResponse,
ScreenerSyncRequest, ScreenerSyncRequest,
@@ -219,3 +224,128 @@ async def screener_sync_status(session: AsyncSession = Depends(get_session)) ->
"""同步任务状态 + 数据实况(最新交易日/行数/ready""" """同步任务状态 + 数据实况(最新交易日/行数/ready"""
status = await market_sync.get_sync_status(session) status = await market_sync.get_sync_status(session)
return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields}) 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 = 260, session: AsyncSession = Depends(get_session)
) -> PreviewResponse:
"""个股详情预览日线qfq 全量缓存,未缓存/过期自动拉取,失败退 market_daily 近段)
+ 全套指标indicators.py 单一事实源)+ 最新截面信息卡。"""
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()
# --- 日线candles(qfq 全量) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
source = "qfq"
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
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)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = _rows_to_bars(rows)
if not bars:
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)
for r in res.scalars()
]
if not bars:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# --- 指标(在全量历史上计算后截尾,保证预热正确) ---
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 (5, 10, 20, 60)},
"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:
group[key] = group[key][-limit:]
# --- 信息卡stock_basic + 最新 market_daily + 与其对齐的快照(避免混用不同交易日) ---
sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first()
ds = None
if md is not None:
# 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见)
ds = (
await session.execute(
select(DailySnapshot).where(
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == md.trade_date
)
)
).scalars().first()
if ds is None:
ds = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.ts_code == ts_code).order_by(DailySnapshot.trade_date.desc()).limit(1)
)
).scalars().first()
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 if sb else ts_code,
industry=sb.industry if sb else None,
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, # 千元 -> 亿元
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,
total_mv=_yi(ds.total_mv) if ds else None,
circ_mv=_yi(ds.circ_mv) if ds else None,
)
candles = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume)
for b in bars[-limit:]
]
return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators)

View File

@@ -9,8 +9,11 @@ class Base(DeclarativeBase):
"""所有 ORM 模型的基类。""" """所有 ORM 模型的基类。"""
# echo=False生产环境可用连接池参数调优 # echo=False远程 PG 的空闲连接可能被中间层断开pre_ping + recycle 自动剔除死连接
engine = create_async_engine(settings.database_url, echo=False, future=True) engine = create_async_engine(
settings.database_url, echo=False, future=True,
pool_pre_ping=True, pool_recycle=1800,
)
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)

View File

@@ -167,3 +167,38 @@ class ScreenerSyncStatus(BaseModel):
last_trade_date: datetime | None = None last_trade_date: datetime | None = None
last_synced_at: datetime | None = None last_synced_at: datetime | None = None
stats: dict[str, int] = Field(default_factory=dict) # stocks/daily_rows/snapshot_rows/dates stats: dict[str, int] = Field(default_factory=dict) # stocks/daily_rows/snapshot_rows/dates
# ---------- 个股详情预览(选股结果点入,全屏同花顺/通达信式) ----------
class PreviewInfoOut(BaseModel):
ts_code: str
symbol: str
name: str
industry: str | None = None
area: str | None = None
market: str | None = None # 主板/创业板/科创板/北交所
list_date: str | None = None
trade_date: datetime | None = None # 行情/信息卡数据基准交易日
open: float | None = None
high: float | None = None
low: float | None = None
close: float | None = None
pre_close: float | None = None
pct_chg: float | None = None # 日涨跌幅 %
volume_hand: float | None = None # 成交量(手)
amount_yi: float | None = None # 成交额(亿元)
turnover_rate: float | None = None # 换手率 %
pe_ttm: float | None = None
pb: float | None = None
total_mv: float | None = None # 总市值(亿元)
circ_mv: float | None = None # 流通市值(亿元)
class PreviewResponse(BaseModel):
ts_code: str
symbol: str
source: str # qfq=回测缓存全量前复权 | market=近段未复权兜底
info: PreviewInfoOut
candles: list[CandleOut]
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
# indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}}