提交
This commit is contained in:
@@ -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 的最近有筹码数据的交易日截面;缺省取最新。
|
||||
价格/成本均按 adjust(bfq/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_latest,hfq=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")
|
||||
|
||||
@@ -66,32 +66,3 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
|
||||
def fetch_chips(ts_code: str, end: str | None = None):
|
||||
"""拉取筹码分布截面:cyq_perf(成本分位/获利比例/平均成本)+ cyq_chips(价位→占比)。
|
||||
|
||||
end 为 YYYYMMDD 参考日:取 <=end 的最近有数据交易日(周/月 K 线由前端换算成
|
||||
周期末传入,这里只需向前吸附到实际数据日);None 取最新。
|
||||
数据自 2018 年起,早于此返回 (None, [])。返回 (perf_dict|None, [(price, percent)])。
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
pro = get_pro()
|
||||
e = end or datetime.now().strftime("%Y%m%d")
|
||||
if e < "20180101":
|
||||
return None, []
|
||||
# 吸附余量 60 个自然日:覆盖春节等长假 + 月初参考日(如月 K 传上月末)
|
||||
s = (datetime.strptime(e, "%Y%m%d") - timedelta(days=60)).strftime("%Y%m%d")
|
||||
s = max(s, "20180101")
|
||||
|
||||
perf = pro.cyq_perf(ts_code=ts_code, start_date=s, end_date=e)
|
||||
if perf is None or perf.empty:
|
||||
return None, []
|
||||
perf = perf.sort_values("trade_date").iloc[-1] # <=end 的最近一条
|
||||
d = str(perf["trade_date"])
|
||||
chips = pro.cyq_chips(ts_code=ts_code, trade_date=d)
|
||||
rows: list[tuple[float, float]] = []
|
||||
if chips is not None and not chips.empty:
|
||||
rows = [(float(p), float(v)) for p, v in zip(chips["price"], chips["percent"])]
|
||||
return perf.to_dict(), rows
|
||||
|
||||
@@ -413,25 +413,3 @@ class MarketOverviewResponse(BaseModel):
|
||||
amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
|
||||
errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示)
|
||||
|
||||
|
||||
# ---------- 筹码峰(个股详情) ----------
|
||||
class ChipRowOut(BaseModel):
|
||||
price: float # 已按 adjust 换算的价位
|
||||
percent: float # 该价位筹码占比(0~1,全价位合计 ≈1)
|
||||
|
||||
|
||||
class ChipsResponse(BaseModel):
|
||||
ts_code: str
|
||||
trade_date: str | None = None # 筹码截面所在交易日(YYYYMMDD)
|
||||
adjust: str # rows[].price 的复权口径 bfq/qfq/hfq
|
||||
rows: list[ChipRowOut] = Field(default_factory=list)
|
||||
his_low: float | None = None # 历史最低/最高价(换算后)
|
||||
his_high: float | None = None
|
||||
cost_5pct: float | None = None # 成本分位(换算后),5/95 即 90% 筹码成本区间
|
||||
cost_15pct: float | None = None
|
||||
cost_50pct: float | None = None
|
||||
cost_85pct: float | None = None
|
||||
cost_95pct: float | None = None
|
||||
weight_avg: float | None = None # 平均成本(换算后)
|
||||
winner_rate: float | None = None # 获利比例 %
|
||||
error: str | None = None # 无数据时的提示(2018 前无数据等)
|
||||
|
||||
Reference in New Issue
Block a user