From 5029a22de1e64c4bb41292cf3da2ada5737918fd Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Mon, 17 Aug 2026 00:47:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=9F=A5=E8=A1=8C=E7=9F=AD?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 5 +- backend/.env.example | 2 + backend/app/api.py | 81 +++++++++- backend/app/config.py | 3 + backend/app/data/tushare_provider.py | 52 +++++- backend/app/schemas.py | 23 +++ backend/app/screener/market_sync.py | 10 +- backend_run.log | 148 +++++++++++++++++- frontend/src/api/client.ts | 17 ++ frontend/src/api/types.ts | 23 +++ frontend/src/components/DetailKLine.vue | 57 ++++++- .../src/components/StockDetailOverlay.vue | 111 ++++++++++++- 12 files changed, 517 insertions(+), 15 deletions(-) diff --git a/backend/.env b/backend/.env index 1011e36..346377d 100644 --- a/backend/.env +++ b/backend/.env @@ -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 diff --git a/backend/.env.example b/backend/.env.example index fc5e726..e1c45be 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/api.py b/backend/app/api.py index ed900f9..9501bb9 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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 的最近有筹码数据的交易日截面;缺省取最新。 + 价格/成本均按 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 随同步更新后旧缓存失效) + 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_latest,hfq=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 diff --git a/backend/app/config.py b/backend/app/config.py index 5e64b9d..001b970 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 年) diff --git a/backend/app/data/tushare_provider.py b/backend/app/data/tushare_provider.py index 1f00d6c..0c40a33 100644 --- a/backend/app/data/tushare_provider.py +++ b/backend/app/data/tushare_provider.py @@ -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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9e6ee1c..17ee1ba 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 前无数据等) diff --git a/backend/app/screener/market_sync.py b/backend/app/screener/market_sync.py index e0ca46c..f09bfbe 100644 --- a/backend/app/screener/market_sync.py +++ b/backend/app/screener/market_sync.py @@ -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: diff --git a/backend_run.log b/backend_run.log index 7aa2b95..354eafe 100644 --- a/backend_run.log +++ b/backend_run.log @@ -8,4 +8,150 @@ INFO: 127.0.0.1:50860 - "GET /api/health HTTP/1.1" 200 OK INFO: 127.0.0.1:50992 - "GET /api/market/overview HTTP/1.1" 401 Unauthorized INFO: 127.0.0.1:50994 - "GET /api/market/overview HTTP/1.1" 401 Unauthorized WARNING: WatchFiles detected changes in 'app\data\market_overview.py'. Reloading... - \ No newline at end of file + INFO: 127.0.0.1:51975 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:52011 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:52012 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:52240 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52241 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:52250 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:52256 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:52255 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:52271 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52809 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:52880 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:52890 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52982 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52992 - "DELETE /api/screener/queries/2 HTTP/1.1" 204 No Content +INFO: 127.0.0.1:52998 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53002 - "DELETE /api/screener/queries/1 HTTP/1.1" 204 No Content +INFO: 127.0.0.1:53005 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53199 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:53217 - "GET /api/market/overview HTTP/1.1" 200 OK +INFO: 127.0.0.1:53203 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55376 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:55374 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55381 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:55387 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:55386 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:55393 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55407 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55429 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55432 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55443 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:55441 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:55442 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:55456 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55462 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56504 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56519 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56542 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56581 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56599 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56609 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56625 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60121 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:60143 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:60135 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:60142 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60152 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:60154 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:60153 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:60175 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60183 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60412 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:60410 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:60411 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:60421 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60418 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62422 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62418 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62469 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62430 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62463 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62489 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62486 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62503 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62519 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62527 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62493 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62545 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62552 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62562 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62572 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62589 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62581 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62604 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62619 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62633 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62642 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62513 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62595 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62614 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53139 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53151 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53157 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:53156 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53165 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:53163 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:53164 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:53171 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53806 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53819 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53820 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53821 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:53823 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:53826 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:53824 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:53830 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53846 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53853 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53860 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:53859 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53862 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:53863 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:53865 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:53869 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53890 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53892 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53901 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53903 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:53905 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:53908 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:53911 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:53912 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54001 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54083 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54087 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54090 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54096 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54104 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54099 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54109 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54123 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54130 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54141 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54151 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54163 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54219 - "PUT /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:54227 - "PUT /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:54228 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54209 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54252 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:54250 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:54248 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:54261 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54353 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK +INFO: 127.0.0.1:54370 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:54364 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:54384 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54682 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54747 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:54990 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 404 Not Found +INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [48632] using WatchFiles +INFO: Started server process [46460] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: 127.0.0.1:55384 - "GET /api/health HTTP/1.1" 200 OK +INFO: 127.0.0.1:55388 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 401 Unauthorized diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a7abb05..af33c6a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,7 @@ import type { AdjustMode, BacktestRequest, BacktestResponse, + ChipsResponse, CurrentUser, EventBacktestRequest, EventBacktestResponse, @@ -161,6 +162,22 @@ export async function getStockPreview( return (await res.json()) as PreviewResponse; } +// ---------- 筹码分布(个股筹码峰) ---------- +/** 获取某参考日的筹码截面:date 缺省取最新;周/月 K 线由调用方换算成周期末日传入, + * 后端吸附到 <=date 的最近有数据交易日。价格与成本均已按 adjust 口径换算。 */ +export async function getStockChips( + tsCode: string, + opts: { date?: string; adjust?: AdjustMode } = {}, +): Promise { + const q = new URLSearchParams(); + if (opts.date) q.set('date', opts.date); + if (opts.adjust) q.set('adjust', opts.adjust); + const qs = q.toString(); + const res = await apiFetch(`/api/stock/${encodeURIComponent(tsCode)}/chips${qs ? `?${qs}` : ''}`); + if (!res.ok) throw new ApiError(await readError(res, `获取筹码分布失败 (HTTP ${res.status})`), res.status); + return (await res.json()) as ChipsResponse; +} + // ---------- 大盘总览(主页) ---------- export async function getMarketOverview(): Promise { const res = await apiFetch('/api/market/overview'); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ceaf395..409b1ac 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -348,6 +348,29 @@ export interface EventBacktestResponse { total: number; } +// ---------- 筹码分布(cyq_chips/cyq_perf,数据自 2018 年起) ---------- +export interface ChipRow { + price: number; // 已按 adjust 口径换算的价位 + percent: number; // 该价位筹码占比(0~1,全价位合计约 1) +} + +export interface ChipsResponse { + ts_code: string; + trade_date: string | null; // YYYYMMDD 截面日 + adjust: string; + rows: ChipRow[]; + his_low: number | null; + his_high: number | null; + cost_5pct: number | null; + cost_15pct: number | null; + cost_50pct: number | null; + cost_85pct: number | null; + cost_95pct: number | null; + weight_avg: number | null; // 平均成本 + winner_rate: number | null; // 获利比例 % + error: string | null; +} + // ---------- 大盘总览(主页) ---------- export interface IndexQuote { code: string; // 000001.SH / HKTECH / DJI diff --git a/frontend/src/components/DetailKLine.vue b/frontend/src/components/DetailKLine.vue index 0393221..5019c29 100644 --- a/frontend/src/components/DetailKLine.vue +++ b/frontend/src/components/DetailKLine.vue @@ -11,8 +11,14 @@ import { threeWaves, triangle, xabcd, } from '@klinecharts/extension'; import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings'; +import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators'; import type { Candle, TooltipField } from '@/api/types'; +// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。 +// g 闭包的 PV/allData 是本组件实例级的,切股会重挂新实例——注册表每次挂载 +// 重注册知行模板换绑新实例的 g,幂等注册会永远读第一只股票的数据(线全错) +registerCustomIndicators((k, ts) => g(k)(ts)); + // 扩展画线模板一次性全局注册(registerOverlay 是全局的,模块加载时执行一次即可) for (const t of [ abcd, xabcd, threeWaves, fiveWaves, eightWaves, anyWaves, arrow, triangle, parallelogram, @@ -85,6 +91,14 @@ const props = defineProps<{ subHeights: Record; /** 主图是否叠加布林带 */ showBoll: boolean; + /** 主图是否叠加知行指标(趋势线+多空线,纯前端计算) */ + showZhixing: boolean; + /** 知行指标图例行的板块文本(通达信 HYBLOCK+DYBLOCK 的近似,行业+地域) */ + zhixingBlocks?: string; + /** 主图是否叠加筹码峰(cyq 截面横向直方图,纯 draw 绘制) */ + showChips: boolean; + /** 筹码峰截面数据(父组件按 当前窗口末根/十字线 所在周期取数;null=暂无) */ + chips?: ChipsExt | null; /** K线周期标签(仅用于 MA 指标名缓存 key) */ timeframe: string; /** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */ @@ -100,6 +114,8 @@ const props = defineProps<{ const emit = defineEmits<{ /** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */ (e: 'centerMiss', ts: number): void; + /** 十字线落在某根K线上(开启筹码峰时逐根上报,父组件防抖后取该周期筹码截面) */ + (e: 'crosshairDate', ts: number): void; }>(); // A股语义色(黑底高对比);UP/DOWN 跟随设置中的涨跌配色 @@ -128,6 +144,14 @@ const g = (k: string) => (ts: number) => { return i >= 0 ? PV[k]?.[i] ?? undefined : undefined; }; +// 知行序列:在整段 allData 上预计算写入 PV(MA114 预热 113 根,比首吐窗口 240 根长, +// 必须在全部已加载历史上算才能覆盖整屏);build 与左滑前插后各跑一次 +function applyZhixingSeries() { + const s = computeZhixingSeries(allData.map((b) => b.close)); + PV['zx_trend'] = s.trend; + PV['zx_dkx'] = s.dkx; +} + // 动态 MA:按周期组合注册一次(figures 的 key 必须静态,故按签名建缓存) // 前端直接从收盘价计算 MA,无需后端重拉数据,切换均线周期即时响应 const _maReg = new Set(); @@ -285,6 +309,7 @@ async function ensureOlder(myEpoch: number): Promise { // 注:klinecharts v10 数据只经 dataLoader 流入、无 updateData, // 已渲染的半周期首K无法原地刷新;替换 allData/PV 保证后续翻页与重建使用完整聚合。 hasMore = page.hasMore; + applyZhixingSeries(); // allData 已前插,知行序列整体重算(EMA 前缀/MA114 预热自愈) failCount = 0; } catch { // 网络失败:保留 hasMore,下次左缘触发时重试;连续 3 次失败才放弃 @@ -412,6 +437,8 @@ function bindCrosshair(ch: Chart) { const idx = ts != null ? idxOfTs(ts) : -1; // 全量数组与指标序列按索引对齐 if (idx < 0) { hover.value = null; return; } const k = allData[idx]; + // 筹码峰跟随:十字线逐根上报(父组件防抖取截面);不开启时不上报,避免无谓事件 + if (props.showChips) emit('crosshairDate', k.timestamp); const prev = idx > 0 ? allData[idx - 1] : null; const diff = prev ? k.close - prev.close : null; const chg = prev ? (diff! / prev.close) * 100 : null; @@ -635,6 +662,20 @@ function placeTradeTip(px: number, py: number, rowCount: number): { x: number; y return { x, y }; } +// ---------- 筹码峰指标同步(不重建图表:createIndicator 不支持原地改 extendData, +// 先删后建即可;数据/开关变化走这里,保留滚动位置与用户画线) ---------- +function syncChipsIndicator() { + if (!chart) return; + const exists = chart.getIndicators().some((i) => i.name === CHIPS_NAME); + const want = props.showChips && !!props.chips; + if (want) { + if (exists) chart.removeIndicator({ name: CHIPS_NAME }); + chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined }); + } else if (exists) { + chart.removeIndicator({ name: CHIPS_NAME }); + } +} + function build() { if (!container.value || props.candles.length === 0) return; UP = settings.upHex; @@ -654,6 +695,7 @@ function build() { timestamp: new Date(k.ts).getTime(), open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume, })); + applyZhixingSeries(); served = 0; hasMore = props.hasMore; fetching = null; @@ -716,9 +758,17 @@ function build() { ch.setSymbol({ ticker: props.ticker }); ch.setPeriod({ type: 'day', span: 1 }); - // 主图:MA(周期可配置)恒开,BOLL 可选 + // 主图:MA(周期可配置)恒开,BOLL / 知行可选 ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' }); if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' }); + if (props.showZhixing) { + // calcParams 用于图例显示「知行(14,28,57,114)」(通达信式);板块文本经 extendData 注入图例行 + ch.createIndicator({ + name: ZHIXING_NAME, paneId: 'candle_pane', + calcParams: DKX_PERIODS, extendData: { blocks: props.zhixingBlocks }, + }); + } + if (props.showChips) syncChipsIndicator(); // 筹码峰(数据未到时 draw 直接空转,到货后由 watch 补建) // 副图按用户顺序创建,并设置用户高度;主图吃剩余高度。 // minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致) @@ -773,7 +823,10 @@ function teardown() { onMounted(build); onBeforeUnmount(teardown); -watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true }); +watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true }); +// 筹码峰开关/截面变化:只同步该指标,不重建图表(保留滚动位置与用户画线; +// 十字线跟随会高频更新截面,重建会闪 + 丢状态) +watch(() => [props.showChips, props.chips], syncChipsIndicator, { deep: true }); // 买卖点数据变化(导入/清空/开关显示):只重画标记,不重建图表(保留滚动位置与用户画线) watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true }); // 涨跌配色切换:重建图表以应用新颜色 diff --git a/frontend/src/components/StockDetailOverlay.vue b/frontend/src/components/StockDetailOverlay.vue index 27f0c6a..922aec0 100644 --- a/frontend/src/components/StockDetailOverlay.vue +++ b/frontend/src/components/StockDetailOverlay.vue @@ -1,14 +1,15 @@