This commit is contained in:
2026-09-05 00:12:19 +08:00
parent 991d1bf343
commit ad9245abdd
12 changed files with 287 additions and 595 deletions

View File

@@ -3,8 +3,8 @@
GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
GET /api/stock/{code}/chips 个股筹码峰cyq_chips/cyq_perf按复权口径换算
GET /api/market/overview 主页大盘总览A 股/港美指数 + 两市市值成交统计)
GET /api/market/index-candles 上证指数全量 K 线1d/1w/1M/1y 聚合)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
POST /api/screener/sync 启动全市场数据同步(后台任务)
@@ -18,6 +18,7 @@ import json
from datetime import datetime
import pandas as pd
from pydantic import TypeAdapter
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import delete, func, select, text
@@ -30,8 +31,9 @@ from .auth import require_user
from .backtest.events import EventEngineError, run_event_backtest
from .backtest.strategies import build_strategy
from .config import settings
from .data import fetcher, repository, tushare_provider
from .data import fetcher, repository
from .data.aggregation import bars_per_year, resample_bars
from .data.index_series import SH_INDEX, get_index_daily
from .data.market_overview import MarketOverviewError, fetch_overview
from .data.symbols import plain_code
from .db import async_session, get_session
@@ -53,8 +55,6 @@ from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
ChipRowOut,
ChipsResponse,
EquityPoint,
EventBacktestRequest,
EventBacktestResponse,
@@ -133,6 +133,8 @@ def _rows_to_bars(rows) -> list[Bar]:
_ADJUST_MODES = ("bfq", "qfq", "hfq")
# MA 全量集合(前端已改为本地计算 MA后端始终返回此集合以保证缓存一致
_FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
# 指数 K 线支持的周期(日线基底聚合)
_INDEX_TIMEFRAMES = ("1d", "1w", "1M", "1y")
# 信息卡一条 SQL 拿全stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot
# LATERAL 单条替换原两条查询语义不变target 为 NULL 时全按最新日兜底)
@@ -409,6 +411,32 @@ async def get_market_overview(session: AsyncSession = Depends(get_session)) -> M
return MarketOverviewResponse(**data)
@router.get("/market/index-candles", response_model=list[CandleOut])
async def get_index_candles(timeframe: str = "1d") -> Response:
"""上证指数全量 K 线日线为基底tushare index_daily进程内+Redis 缓存),
聚合到 1d/1w/1M/1y。收盘口径数据随 EOD 更新,与总览 spark 一致)。"""
if timeframe not in _INDEX_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(_INDEX_TIMEFRAMES)}")
key = f"idxkj:{cache.digest('idxc', SH_INDEX, timeframe)}"
cached = await _cached_json_response(key)
if cached is not None:
return cached
try:
bars = resample_bars(await get_index_daily(), timeframe)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}")
outs = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=None)
for b in bars
]
# pydantic-core 序列化(与 _raw_json 同款历史不可变、TTL 兜到当日更新
raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode()
cache.local_set(key, raw, ttl=300)
await cache.cache_set(key, raw, ttl=7200)
return Response(content=raw, media_type="application/json")
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
@@ -1112,84 +1140,3 @@ async def screener_preview(
cache.local_set(f"pvj:{cache_key}", raw, ttl=120)
asyncio.create_task(cache.cache_set(f"pvj:{cache_key}", raw, ttl=600))
return Response(content=raw, media_type="application/json")
@router.get("/stock/{ts_code}/chips", response_model=ChipsResponse)
async def stock_chips(
ts_code: str, date: str | None = None, adjust: str = "qfq",
session: AsyncSession = Depends(get_session),
) -> Response:
"""个股筹码峰Tushare cyq_chips + cyq_perf数据自 2018 年起)。
date=YYYY-MM-DD 为参考日(日 K 传当日;周/月 K 由前端传周期末):返回
<=date 的最近有筹码数据的交易日截面;缺省取最新。
价格/成本均按 adjustbfq/qfq/hfq用 adj_factor 本地换算,与 K 线同口径。
"""
if adjust not in _ADJUST_MODES:
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}")
ref: str | None = None
if date:
try:
ref = datetime.strptime(date.strip()[:10], "%Y-%m-%d").strftime("%Y%m%d")
except ValueError:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD")
# 历史截面不可变;键带 candles 版本号adj_factor 随同步更新后旧缓存失效)。
# 同 preview存序列化 JSON 直返j: 前缀),跳过 pydantic 校验/序列化。
cache_key = cache.digest("chips", ts_code, ref or "latest", adjust, await cache.get_version("candles"))
cached = await _cached_json_response(f"chipsj:{cache_key}")
if cached is not None:
return cached
try:
perf, rows = await asyncio.to_thread(tushare_provider.fetch_chips, ts_code, ref)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"筹码数据获取失败: {e}")
if not perf:
resp = ChipsResponse(
ts_code=ts_code, trade_date=None, adjust=adjust,
error="无筹码数据cyq 数据自 2018 年起,或参考日早于数据起点)",
)
raw = _raw_json(resp)
cache.local_set(f"chipsj:{cache_key}", raw, ttl=120)
await cache.cache_set(f"chipsj:{cache_key}", raw, ttl=3600)
return Response(content=raw, media_type="application/json")
d = datetime.strptime(str(perf["trade_date"]), "%Y%m%d")
# 复权换算(与 _adjust_bars 同口径qfq=f(d)/f_latesthfq=f(d)bfq=1
mult = 1.0
if adjust != "bfq":
factors = (await session.execute(
select(AdjFactor.trade_date, AdjFactor.adj_factor)
.where(AdjFactor.ts_code == ts_code, AdjFactor.trade_date <= d)
.order_by(AdjFactor.trade_date)
)).all()
if factors:
latest_f = (await session.execute(
select(AdjFactor.trade_date, AdjFactor.adj_factor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)).first()
f_at = float(factors[-1][1]) # <=d 的最近因子(因子是阶梯函数)
f_latest = float(latest_f[1]) if latest_f else f_at
mult = f_at / f_latest if adjust == "qfq" else f_at
def _px(v) -> float | None:
return None if v is None or v != v else round(float(v) * mult, 3)
resp = ChipsResponse(
ts_code=ts_code,
trade_date=str(perf["trade_date"]),
adjust=adjust,
rows=[ChipRowOut(price=round(p * mult, 3), percent=v) for p, v in rows],
his_low=_px(perf.get("his_low")), his_high=_px(perf.get("his_high")),
cost_5pct=_px(perf.get("cost_5pct")), cost_15pct=_px(perf.get("cost_15pct")),
cost_50pct=_px(perf.get("cost_50pct")), cost_85pct=_px(perf.get("cost_85pct")),
cost_95pct=_px(perf.get("cost_95pct")),
weight_avg=_px(perf.get("weight_avg")),
winner_rate=_px(perf.get("winner_rate")),
)
raw = _raw_json(resp)
cache.local_set(f"chipsj:{cache_key}", raw, ttl=120)
await cache.cache_set(f"chipsj:{cache_key}", raw, ttl=21600)
return Response(content=raw, media_type="application/json")