完善知行短线

This commit is contained in:
2026-08-17 00:47:02 +08:00
parent 436a126e3b
commit 5029a22de1
12 changed files with 517 additions and 15 deletions

View File

@@ -1,5 +1,8 @@
DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock
TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
# TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
TUSHARE_TOKEN=2f7dbca732cdb762eb61bf3ca1b58f0c19a12732346ae06b3f60f4f5
# 15000 积分档走 quicksync 镜像(官方接口对该 token 返回 40101
TUSHARE_API_URL=http://api.quicksync.cn
DATA_ADJUST=qfq
DATA_DEFAULT_START=20200101

View File

@@ -14,6 +14,8 @@ EXPOSE_API_DOCS=true
# ---- 真实数据源Tushare Pro免费版即可----
TUSHARE_TOKEN=你的token
# 15000 积分档 token 需走镜像,填 http://api.quicksync.cn普通 token 留空
TUSHARE_API_URL=
DATA_ADJUST=qfq # 复权qfq 前复权 / hfq 后复权 / 留空不复权
DATA_DEFAULT_START=20200101

View File

@@ -3,6 +3,7 @@
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 股/港美指数 + 两市市值成交统计)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
@@ -11,6 +12,7 @@
"""
from __future__ import annotations
import asyncio
import bisect
import json
from datetime import datetime
@@ -27,7 +29,7 @@ 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
from .data import fetcher, repository, tushare_provider
from .data.aggregation import bars_per_year, resample_bars
from .data.market_overview import MarketOverviewError, fetch_overview
from .data.symbols import plain_code
@@ -50,6 +52,8 @@ from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
ChipRowOut,
ChipsResponse,
EquityPoint,
EventBacktestRequest,
EventBacktestResponse,
@@ -982,3 +986,78 @@ async def screener_preview(
candles=candles, indicators=indicators, has_more=has_more)
await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600)
return resp
@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),
) -> ChipsResponse:
"""个股筹码峰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 随同步更新后旧缓存失效)
cache_key = cache.digest("chips", ts_code, ref or "latest", adjust, await cache.get_version("candles"))
cached = await cache.cache_get(f"chips:{cache_key}")
if cached is not None:
return ChipsResponse.model_validate(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 年起,或参考日早于数据起点)",
)
await cache.cache_set(f"chips:{cache_key}", resp.model_dump(mode="json"), ttl=3600)
return resp
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).where(AdjFactor.ts_code == ts_code, AdjFactor.trade_date <= d)
.order_by(AdjFactor.trade_date)
)).scalars().all()
if factors:
latest_f = (await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)).scalars().first()
f_at = float(factors[-1].adj_factor) # <=d 的最近因子(因子是阶梯函数)
f_latest = float(latest_f.adj_factor) 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")),
)
await cache.cache_set(f"chips:{cache_key}", resp.model_dump(mode="json"), ttl=21600)
return resp

View File

@@ -28,6 +28,9 @@ class Settings(BaseSettings):
# 真实数据源
tushare_token: str = "" # Tushare Pro token主数据源
# 15000 积分档 token 走 quicksync 镜像(官方 api.tushare.pro 对其返回 40101
# 留空 = 官方接口。SDK 侧需改写 DataApi.__http_url见 tushare_provider.get_pro
tushare_api_url: str = ""
data_adjust: str = "qfq" # 复权qfq 前复权 / hfq 后复权 / "" 不复权
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年)

View File

@@ -15,14 +15,29 @@ def _parse(date_str: str) -> datetime:
return datetime.strptime(str(date_str), "%Y%m%d")
def get_pro():
"""创建 pro 客户端(统一入口)。
15000 积分档 token 只认 quicksync 镜像(直连 api.tushare.pro 会 40101 token 不对),
官方接入方式是改写 SDK 的私有类属性 DataApi.__http_url——类属性是所有实例
(含 pro_bar 内部自建的 DataApi共用的请求地址打一次补丁全局生效。
"""
import tushare as ts
import tushare.pro.client as client
if not settings.tushare_token:
raise RuntimeError("未配置 TUSHARE_TOKEN")
if settings.tushare_api_url:
client.DataApi._DataApi__http_url = settings.tushare_api_url
ts.set_token(settings.tushare_token)
return ts.pro_api()
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
adjust: str = "qfq") -> list[Bar]:
import tushare as ts # 延迟导入:未装或无 token 时该数据源不可用
if not settings.tushare_token:
raise RuntimeError("未配置 TUSHARE_TOKEN")
ts.set_token(settings.tushare_token)
pro = ts.pro_api()
pro = get_pro() # 含镜像补丁quicksync+ token 检查
ts_code = to_ts_code(code)
end = end or datetime.now().strftime("%Y%m%d")
@@ -51,3 +66,32 @@ 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

View File

@@ -402,3 +402,26 @@ class MarketOverviewResponse(BaseModel):
indexes: list[IndexQuoteOut] = []
stats: MarketStatsOut | None = None
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 前无数据等)

View File

@@ -57,13 +57,15 @@ def _call_retry(fn, *args, **kwargs):
def _get_pro():
"""token 检查 + 返回 pro api 客户端(同步对象,调用需 to_thread 包裹)。"""
"""token 检查 + 返回 pro api 客户端(同步对象,调用需 to_thread 包裹)。
经 tushare_provider.get_pro 统一走镜像补丁15000 积分档 token 只认 quicksync
"""
if not settings.tushare_token:
raise ScreenerError("未配置 TUSHARE_TOKEN无法同步全市场数据backend/.env")
import tushare as ts
from ..data.tushare_provider import get_pro
ts.set_token(settings.tushare_token)
return ts.pro_api()
return get_pro()
def _parse_d(s: str) -> datetime: