完善知行短线

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 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_ADJUST=qfq
DATA_DEFAULT_START=20200101 DATA_DEFAULT_START=20200101

View File

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

View File

@@ -3,6 +3,7 @@
GET /api/health 健康检查 GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合) GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数) GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
GET /api/stock/{code}/chips 个股筹码峰cyq_chips/cyq_perf按复权口径换算
GET /api/market/overview 主页大盘总览A 股/港美指数 + 两市市值成交统计) GET /api/market/overview 主页大盘总览A 股/港美指数 + 两市市值成交统计)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效 POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选 POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
@@ -11,6 +12,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import bisect import bisect
import json import json
from datetime import datetime from datetime import datetime
@@ -27,7 +29,7 @@ from .auth import require_user
from .backtest.events import EventEngineError, run_event_backtest from .backtest.events import EventEngineError, run_event_backtest
from .backtest.strategies import build_strategy from .backtest.strategies import build_strategy
from .config import settings 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.aggregation import bars_per_year, resample_bars
from .data.market_overview import MarketOverviewError, fetch_overview from .data.market_overview import MarketOverviewError, fetch_overview
from .data.symbols import plain_code from .data.symbols import plain_code
@@ -50,6 +52,8 @@ from .schemas import (
BacktestRequest, BacktestRequest,
BacktestResponse, BacktestResponse,
CandleOut, CandleOut,
ChipRowOut,
ChipsResponse,
EquityPoint, EquityPoint,
EventBacktestRequest, EventBacktestRequest,
EventBacktestResponse, EventBacktestResponse,
@@ -982,3 +986,78 @@ async def screener_preview(
candles=candles, indicators=indicators, has_more=has_more) candles=candles, indicators=indicators, has_more=has_more)
await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600) await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600)
return resp 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主数据源 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_adjust: str = "qfq" # 复权qfq 前复权 / hfq 后复权 / "" 不复权
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年) 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") 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, def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
adjust: str = "qfq") -> list[Bar]: adjust: str = "qfq") -> list[Bar]:
import tushare as ts # 延迟导入:未装或无 token 时该数据源不可用 import tushare as ts # 延迟导入:未装或无 token 时该数据源不可用
if not settings.tushare_token: pro = get_pro() # 含镜像补丁quicksync+ token 检查
raise RuntimeError("未配置 TUSHARE_TOKEN")
ts.set_token(settings.tushare_token)
pro = ts.pro_api()
ts_code = to_ts_code(code) ts_code = to_ts_code(code)
end = end or datetime.now().strftime("%Y%m%d") 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 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] = [] indexes: list[IndexQuoteOut] = []
stats: MarketStatsOut | None = None stats: MarketStatsOut | None = None
errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示) 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(): 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: if not settings.tushare_token:
raise ScreenerError("未配置 TUSHARE_TOKEN无法同步全市场数据backend/.env") raise ScreenerError("未配置 TUSHARE_TOKEN无法同步全市场数据backend/.env")
import tushare as ts from ..data.tushare_provider import get_pro
ts.set_token(settings.tushare_token) return get_pro()
return ts.pro_api()
def _parse_d(s: str) -> datetime: def _parse_d(s: str) -> datetime:

View File

@@ -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: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 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... WARNING: WatchFiles detected changes in 'app\data\market_overview.py'. Reloading...
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

View File

@@ -2,6 +2,7 @@ import type {
AdjustMode, AdjustMode,
BacktestRequest, BacktestRequest,
BacktestResponse, BacktestResponse,
ChipsResponse,
CurrentUser, CurrentUser,
EventBacktestRequest, EventBacktestRequest,
EventBacktestResponse, EventBacktestResponse,
@@ -161,6 +162,22 @@ export async function getStockPreview(
return (await res.json()) as PreviewResponse; return (await res.json()) as PreviewResponse;
} }
// ---------- 筹码分布(个股筹码峰) ----------
/** 获取某参考日的筹码截面date 缺省取最新;周/月 K 线由调用方换算成周期末日传入,
* 后端吸附到 <=date 的最近有数据交易日。价格与成本均已按 adjust 口径换算。 */
export async function getStockChips(
tsCode: string,
opts: { date?: string; adjust?: AdjustMode } = {},
): Promise<ChipsResponse> {
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<MarketOverview> { export async function getMarketOverview(): Promise<MarketOverview> {
const res = await apiFetch('/api/market/overview'); const res = await apiFetch('/api/market/overview');

View File

@@ -348,6 +348,29 @@ export interface EventBacktestResponse {
total: number; 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 { export interface IndexQuote {
code: string; // 000001.SH / HKTECH / DJI code: string; // 000001.SH / HKTECH / DJI

View File

@@ -11,8 +11,14 @@ import {
threeWaves, triangle, xabcd, threeWaves, triangle, xabcd,
} from '@klinecharts/extension'; } from '@klinecharts/extension';
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings'; 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'; import type { Candle, TooltipField } from '@/api/types';
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
// g 闭包的 PV/allData 是本组件实例级的,切股会重挂新实例——注册表每次挂载
// 重注册知行模板换绑新实例的 g幂等注册会永远读第一只股票的数据线全错
registerCustomIndicators((k, ts) => g(k)(ts));
// 扩展画线模板一次性全局注册registerOverlay 是全局的,模块加载时执行一次即可) // 扩展画线模板一次性全局注册registerOverlay 是全局的,模块加载时执行一次即可)
for (const t of [ for (const t of [
abcd, xabcd, threeWaves, fiveWaves, eightWaves, anyWaves, arrow, triangle, parallelogram, abcd, xabcd, threeWaves, fiveWaves, eightWaves, anyWaves, arrow, triangle, parallelogram,
@@ -85,6 +91,14 @@ const props = defineProps<{
subHeights: Record<string, number>; subHeights: Record<string, number>;
/** 主图是否叠加布林带 */ /** 主图是否叠加布林带 */
showBoll: boolean; showBoll: boolean;
/** 主图是否叠加知行指标(趋势线+多空线,纯前端计算) */
showZhixing: boolean;
/** 知行指标图例行的板块文本(通达信 HYBLOCK+DYBLOCK 的近似,行业+地域) */
zhixingBlocks?: string;
/** 主图是否叠加筹码峰cyq 截面横向直方图,纯 draw 绘制) */
showChips: boolean;
/** 筹码峰截面数据(父组件按 当前窗口末根/十字线 所在周期取数null=暂无) */
chips?: ChipsExt | null;
/** K线周期标签仅用于 MA 指标名缓存 key */ /** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string; timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */ /** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
@@ -100,6 +114,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
/** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */ /** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */
(e: 'centerMiss', ts: number): void; (e: 'centerMiss', ts: number): void;
/** 十字线落在某根K线上开启筹码峰时逐根上报父组件防抖后取该周期筹码截面 */
(e: 'crosshairDate', ts: number): void;
}>(); }>();
// A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色 // A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色
@@ -128,6 +144,14 @@ const g = (k: string) => (ts: number) => {
return i >= 0 ? PV[k]?.[i] ?? undefined : undefined; return i >= 0 ? PV[k]?.[i] ?? undefined : undefined;
}; };
// 知行序列:在整段 allData 上预计算写入 PVMA114 预热 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按周期组合注册一次figures 的 key 必须静态,故按签名建缓存)
// 前端直接从收盘价计算 MA无需后端重拉数据切换均线周期即时响应 // 前端直接从收盘价计算 MA无需后端重拉数据切换均线周期即时响应
const _maReg = new Set<string>(); const _maReg = new Set<string>();
@@ -285,6 +309,7 @@ async function ensureOlder(myEpoch: number): Promise<void> {
// 注klinecharts v10 数据只经 dataLoader 流入、无 updateData // 注klinecharts v10 数据只经 dataLoader 流入、无 updateData
// 已渲染的半周期首K无法原地刷新替换 allData/PV 保证后续翻页与重建使用完整聚合。 // 已渲染的半周期首K无法原地刷新替换 allData/PV 保证后续翻页与重建使用完整聚合。
hasMore = page.hasMore; hasMore = page.hasMore;
applyZhixingSeries(); // allData 已前插知行序列整体重算EMA 前缀/MA114 预热自愈)
failCount = 0; failCount = 0;
} catch { } catch {
// 网络失败:保留 hasMore下次左缘触发时重试连续 3 次失败才放弃 // 网络失败:保留 hasMore下次左缘触发时重试连续 3 次失败才放弃
@@ -412,6 +437,8 @@ function bindCrosshair(ch: Chart) {
const idx = ts != null ? idxOfTs(ts) : -1; // 全量数组与指标序列按索引对齐 const idx = ts != null ? idxOfTs(ts) : -1; // 全量数组与指标序列按索引对齐
if (idx < 0) { hover.value = null; return; } if (idx < 0) { hover.value = null; return; }
const k = allData[idx]; const k = allData[idx];
// 筹码峰跟随:十字线逐根上报(父组件防抖取截面);不开启时不上报,避免无谓事件
if (props.showChips) emit('crosshairDate', k.timestamp);
const prev = idx > 0 ? allData[idx - 1] : null; const prev = idx > 0 ? allData[idx - 1] : null;
const diff = prev ? k.close - prev.close : null; const diff = prev ? k.close - prev.close : null;
const chg = prev ? (diff! / prev.close) * 100 : 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 }; 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() { function build() {
if (!container.value || props.candles.length === 0) return; if (!container.value || props.candles.length === 0) return;
UP = settings.upHex; UP = settings.upHex;
@@ -654,6 +695,7 @@ function build() {
timestamp: new Date(k.ts).getTime(), timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume, open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
})); }));
applyZhixingSeries();
served = 0; served = 0;
hasMore = props.hasMore; hasMore = props.hasMore;
fetching = null; fetching = null;
@@ -716,9 +758,17 @@ function build() {
ch.setSymbol({ ticker: props.ticker }); ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 }); ch.setPeriod({ type: 'day', span: 1 });
// 主图MA周期可配置恒开BOLL 可选 // 主图MA周期可配置恒开BOLL / 知行可选
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' }); ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', 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 下限一致) // minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致)
@@ -773,7 +823,10 @@ function teardown() {
onMounted(build); onMounted(build);
onBeforeUnmount(teardown); 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 }); watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色 // 涨跌配色切换:重建图表以应用新颜色

View File

@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { import {
addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi, addWatchlist, clearTrades, getStockChips, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
importTrades, removeWatchlist, importTrades, removeWatchlist,
} from '@/api/client'; } from '@/api/client';
import type { import type {
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField, ChartLayoutPrefs, ChipsResponse, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField,
TradesImportResponse, UserTrade, TradesImportResponse, UserTrade,
} from '@/api/types'; } from '@/api/types';
import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings'; import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
import type { ChipsExt } from '@/indicators';
import DetailKLine from './DetailKLine.vue'; import DetailKLine from './DetailKLine.vue';
const props = defineProps<{ const props = defineProps<{
@@ -67,6 +68,91 @@ const maPeriods = computed<number[]>(() => layout.value.maPeriods);
const subHeights = computed(() => layout.value.subHeights); const subHeights = computed(() => layout.value.subHeights);
const tooltipFields = computed<TooltipField[]>(() => layout.value.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS); const tooltipFields = computed<TooltipField[]>(() => layout.value.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS);
const showBoll = ref(false); const showBoll = ref(false);
const showZhixing = ref(false);
// 知行指标图例行的板块文本(通达信 HYBLOCK+' '+DYBLOCK 的近似GNBLOCK 概念板块无数据源)
const zhixingBlocks = computed(() => {
const info = data.value?.info;
if (!info) return undefined;
return [info.industry, info.area].filter(Boolean).join(' ') || undefined;
});
// ---------- 筹码峰cyq 截面,跟随 日/周/月K 的当前周期 + 十字线) ----------
const showChips = ref(false);
const chipsExt = ref<ChipsExt | null>(null);
let chipsToken = 0; // 请求序号:切股/切口径瞬间乱序返回的旧截面不能回填
let chipsKey = ''; // 已请求去重键code|adjust|参考日)
function toChipsExt(res: ChipsResponse, refClose: number): ChipsExt {
return {
rows: res.rows.map((r) => ({ price: r.price, percent: r.percent })),
date: res.trade_date ?? undefined,
refClose,
winner: res.winner_rate,
avg: res.weight_avg,
costLow: res.cost_5pct,
costHigh: res.cost_95pct,
error: res.error ?? undefined,
};
}
/** 筹码锚点(同花顺式):返回 参考日 + 锚定 bar 收盘价(获利/套牢的分界价)。
* 日K=bar 当日;周/月/年K 的 bar ts 是周期首日,参考日=下一根 bar 首日的前一天
* (最后一根=今天,后端再吸附到 <=参考日 的最近有数据交易日)。
* idx 缺省=窗口最后一根(跟随当前可视周期);十字线跟随时传落点下标。
* 注:翻页前插的更早 bar 不在 data.candles 里,十字线落到那些 bar 上时沿用上一截面。 */
function chipsAnchor(idx?: number): { ref: string; close: number } | undefined {
const bars = data.value?.candles;
if (!bars?.length) return undefined;
const i = idx != null && idx >= 0 && idx < bars.length ? idx : bars.length - 1;
if (timeframe.value === '1d') return { ref: bars[i].ts.slice(0, 10), close: bars[i].close };
const nextTs = i + 1 < bars.length ? new Date(bars[i + 1].ts).getTime() - 86400000 : Date.now();
return { ref: fmtDashDate(Math.min(nextTs, Date.now())), close: bars[i].close };
}
async function loadChips(anchorIdx?: number) {
if (!showChips.value) return;
const anchor = chipsAnchor(anchorIdx);
if (!anchor) return;
const key = `${active.value}|${adjust.value}|${anchor.ref}`;
if (key === chipsKey) return; // 同股同口径同参考日不重复请求(后端有 Redis 缓存,前端也省一次往返)
chipsKey = key;
const token = ++chipsToken;
try {
const res = await getStockChips(active.value, { date: anchor.ref, adjust: adjust.value });
if (token === chipsToken) chipsExt.value = toChipsExt(res, anchor.close);
} catch {
if (token === chipsToken) chipsExt.value = { rows: [], refClose: anchor.close, error: '筹码数据获取失败' };
}
}
/** K线数据就绪/切换(切股、复权、周期、日期跳转都经 load() 换 data筹码重置到新窗口末根 */
watch(data, () => {
chipsKey = '';
chipHoverTs = null;
chipsExt.value = null;
void loadChips();
});
/** 十字线跟随(防抖 350ms落在哪根K线就取那个周期的截面离开图表保留最后截面通达信同款 */
let chipHoverTimer: ReturnType<typeof setTimeout> | null = null;
let chipHoverTs: number | null = null;
function onCrosshairDate(ts: number) {
if (!showChips.value || chipHoverTs === ts) return;
chipHoverTs = ts;
if (chipHoverTimer) clearTimeout(chipHoverTimer);
chipHoverTimer = setTimeout(() => {
chipHoverTimer = null;
const bars = data.value?.candles;
if (!bars) return;
const idx = bars.findIndex((b) => new Date(b.ts).getTime() === ts);
if (idx >= 0) void loadChips(idx);
}, 350);
}
function toggleChips() {
showChips.value = !showChips.value;
if (showChips.value) { chipsKey = ''; chipHoverTs = null; void loadChips(); }
}
function toggleSub(key: string) { function toggleSub(key: string) {
const cur = subPanes.value; const cur = subPanes.value;
@@ -580,6 +666,22 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
title="主图叠加布林带" title="主图叠加布林带"
@click="showBoll = !showBoll" @click="showBoll = !showBoll"
>BOLL</button> >BOLL</button>
<!-- 知行通达信导入的自定义主图指标趋势线+多空线 -->
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showZhixing ? 'border-[#E61851] bg-[#E61851] text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
title="知行指标:短期趋势线 EMA(EMA(C,10),10) + 多空线 (MA14+MA28+MA57+MA114)/4"
@click="showZhixing = !showZhixing"
>知行</button>
<!-- 筹码峰右侧横向线状直方图选中K线下方获利盘红/上方套牢盘蓝+平均成本虚线 -->
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showChips ? 'border-cyan-400 bg-cyan-400 text-black' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
title="筹码峰各价位持仓占比选中K线分界下方获利盘红/上方套牢盘蓝),随日/周/月K与十字线切换截面"
@click="toggleChips"
>筹码峰</button>
<!-- 实盘交易点交割单导入的买卖标记 --> <!-- 实盘交易点交割单导入的买卖标记 -->
<button <button
type="button" type="button"
@@ -708,6 +810,11 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:ma-periods="maPeriods" :ma-periods="maPeriods"
:sub-heights="subHeights" :sub-heights="subHeights"
:show-boll="showBoll" :show-boll="showBoll"
:show-zhixing="showZhixing"
:zhixing-blocks="zhixingBlocks"
:show-chips="showChips"
:chips="chipsExt"
@crosshair-date="onCrosshairDate"
:timeframe="timeframe" :timeframe="timeframe"
:tooltip-fields="tooltipFields" :tooltip-fields="tooltipFields"
:center-ts="jumpTs" :center-ts="jumpTs"