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

View File

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

View File

@@ -66,32 +66,3 @@ 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

@@ -413,25 +413,3 @@ class MarketOverviewResponse(BaseModel):
amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中) amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
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

@@ -68,3 +68,207 @@ INFO: 127.0.0.1:16599 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:16600 - "GET /api/market/overview HTTP/1.1" 200 OK INFO: 127.0.0.1:16600 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:16601 - "GET /api/market/overview HTTP/1.1" 200 OK INFO: 127.0.0.1:16601 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:16602 - "GET /api/market/overview HTTP/1.1" 200 OK INFO: 127.0.0.1:16602 - "GET /api/market/overview HTTP/1.1" 200 OK
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 [43156] using WatchFiles
INFO: Started server process [35852]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:16663 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:16664 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:16668 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:16670 - "GET /api/market/overview HTTP/1.1" 200 OK
WARNING: WatchFiles detected changes in 'scripts\_tmp_crosscheck.py'. Reloading...
INFO: 127.0.0.1:19181 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:19196 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:19198 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:19197 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:19510 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:19513 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:22090 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:22089 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22101 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22107 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22106 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22111 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22115 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22123 - "GET /api/trades?ts_code=000002.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22124 - "GET /api/screener/preview/000002.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22126 - "GET /api/screener/preview/000002.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22138 - "GET /api/trades?ts_code=000006.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22139 - "GET /api/screener/preview/000006.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22141 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22173 - "GET /api/stocks?search=%E9%A2%86%E7%9B%8A%E7%A7%91%E6%8A%80&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22175 - "GET /api/stocks?search=%E9%A2%86%E7%9B%8A%E7%A7%91&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22179 - "GET /api/stocks?search=%E9%A2%86%E7%9B%8A&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22181 - "GET /api/stocks?search=%E9%A2%86&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22190 - "GET /api/trades?ts_code=002600.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22192 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22191 - "GET /api/screener/preview/002600.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22195 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22199 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22203 - "GET /api/stock/002600.SZ/chips?date=2026-09-04&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:22213 - "GET /api/stock/002600.SZ/chips?date=2026-09-04&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:22231 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22235 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22237 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22242 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22245 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22247 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22251 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22254 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22256 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22260 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22262 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22265 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22269 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22271 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22275 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22279 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22283 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22286 - "GET /api/screener/preview/002600.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22320 - "POST /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22322 - "GET /api/stocks?search=%E9%A2%86&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22332 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22334 - "GET /api/stocks?search=%E6%B5%B7%E8%9E%BA&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22338 - "POST /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22343 - "GET /api/trades?ts_code=600585.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:22349 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22348 - "GET /api/screener/preview/600585.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22353 - "GET /api/screener/preview/600585.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22368 - "GET /api/screener/preview/600585.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22483 - "GET /api/stocks?search=%E6%B5%B7%E8%9E%BA&watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22485 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22501 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22499 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22500 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22503 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22508 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22571 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22579 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22577 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22581 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22585 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22587 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22594 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22596 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22598 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22601 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22606 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=10%2C20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22609 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22611 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=10%2C20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22615 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=20&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22618 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22621 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22624 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22628 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22630 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22635 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22638 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22642 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22644 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22663 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22667 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22676 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22678 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22805 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:22820 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:22819 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22817 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22824 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22823 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22829 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22831 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22845 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:22847 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22852 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22853 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:22856 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22855 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22861 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22859 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22872 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:22874 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:22882 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22883 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:22889 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22885 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22898 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22895 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:22908 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:22907 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:22913 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:22911 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23054 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23056 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23064 - "GET /api/trades?ts_code=000783.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23065 - "GET /api/screener/preview/000783.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:23071 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:23069 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23074 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23085 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:23087 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23091 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23099 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:23094 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:23098 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23105 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23103 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23119 - "GET /api/stock/002074.SZ/chips?date=2026-09-04&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:23328 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23331 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23335 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23359 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23360 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:23362 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23381 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2021-04-28 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23383 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2018-01-10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23405 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1w HTTP/1.1" 200 OK
INFO: 127.0.0.1:23407 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23410 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1w&end=2017-01-16 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23453 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1w HTTP/1.1" 200 OK
INFO: 127.0.0.1:23452 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23456 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23461 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1w&end=2017-01-16 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23466 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23470 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23511 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23510 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23514 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23630 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:23642 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23645 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:23644 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23652 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23651 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23654 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23692 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:23694 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23698 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23704 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:23708 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23705 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23713 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23720 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:23722 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23728 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23729 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:23734 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23731 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:23739 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23753 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:23756 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:23771 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:23772 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:23774 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:23775 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:23773 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
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 [18388] using WatchFiles
INFO: Started server process [4824]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:27988 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:28005 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:28014 - "GET /api/market/overview HTTP/1.1" 401 Unauthorized

View File

@@ -2,7 +2,7 @@ import type {
AdjustMode, AdjustMode,
BacktestRequest, BacktestRequest,
BacktestResponse, BacktestResponse,
ChipsResponse, Candle,
CurrentUser, CurrentUser,
EventBacktestRequest, EventBacktestRequest,
EventBacktestResponse, EventBacktestResponse,
@@ -191,22 +191,6 @@ 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');
@@ -214,6 +198,13 @@ export async function getMarketOverview(): Promise<MarketOverview> {
return (await res.json()) as MarketOverview; return (await res.json()) as MarketOverview;
} }
/** 上证指数全量 K 线(日线基底聚合到目标周期;收盘口径) */
export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`);
if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as Candle[];
}
export async function getStocks(params: { export async function getStocks(params: {
search?: string; search?: string;
market?: string; market?: string;

View File

@@ -260,6 +260,9 @@ export interface ChartLayoutPrefs {
subHeights: Record<string, number>; // 面板高度 px subHeights: Record<string, number>; // 面板高度 px
timeframe?: Timeframe; timeframe?: Timeframe;
tooltipFields?: TooltipField[]; // 浮层显示的指标(顺序即行序;空数组=仅日期头) tooltipFields?: TooltipField[]; // 浮层显示的指标(顺序即行序;空数组=仅日期头)
indexTimeframe?: Timeframe; // 首页上证指数 K 线周期
showBoll?: boolean; // 主图叠加 BOLL
showZhixing?: boolean; // 主图叠加知行指标(趋势线+多空线)
} }
export interface ScreenerQueryItem { export interface ScreenerQueryItem {
@@ -357,29 +360,6 @@ 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,7 +11,8 @@ 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, CHIPS_COL_PX, type ChipsExt } from '@/indicators'; import { darkStyles as sharedDarkStyles } from '@/chartStyles';
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME } from '@/indicators';
import type { Candle, TooltipField } from '@/api/types'; import type { Candle, TooltipField } from '@/api/types';
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。 // 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
@@ -95,10 +96,6 @@ const props = defineProps<{
showZhixing: boolean; showZhixing: boolean;
/** 知行指标图例行的板块文本(通达信 HYBLOCK+DYBLOCK 的近似,行业+地域) */ /** 知行指标图例行的板块文本(通达信 HYBLOCK+DYBLOCK 的近似,行业+地域) */
zhixingBlocks?: string; zhixingBlocks?: string;
/** 主图是否叠加筹码峰cyq 截面横向直方图,纯 draw 绘制) */
showChips: boolean;
/** 筹码峰截面数据(父组件按 当前窗口末根/十字线 所在周期取数null=暂无) */
chips?: ChipsExt | null;
/** K线周期标签仅用于 MA 指标名缓存 key */ /** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string; timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */ /** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
@@ -114,8 +111,6 @@ 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 跟随设置中的涨跌配色
@@ -327,35 +322,9 @@ function maybePrefetch(myEpoch: number) {
} }
function darkStyles() { function darkStyles() {
return { // 共享暗色主题chartStyles.tsUP/DOWN 在 build() 里随 settings 刷新,
grid: { horizontal: { color: '#1C1E24' }, vertical: { color: '#1C1E24' } }, // 故切「绿涨红跌」重建后同样生效
// 内建 VOL 等指标柱的涨跌色缺省是库默认绿涨红跌,与全站语义相反; return sharedDarkStyles(UP, DOWN);
// bars[0] 与库默认项深合并init 时 merge 到 getDefaultStyles只覆盖三个颜色键
// UP/DOWN 在 build() 里随 settings 刷新,故切「绿涨红跌」重建后同样生效
indicator: { bars: [{ upColor: UP, downColor: DOWN, noChangeColor: '#7A818C' }] },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
priceMark: {
high: { color: '#9AA0AA' }, low: { color: '#9AA0AA' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
yAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
crosshair: {
horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#333A45' } },
},
separator: {
color: '#23252B',
// 悬停/拖拽分隔条时的底色(库默认 8% 蓝在纯黑底上不可见,加重为可感知的拖拽提示)
activeBackgroundColor: 'rgba(37, 99, 235, 0.30)',
},
};
} }
// 副图默认高度 // 副图默认高度
@@ -437,8 +406,6 @@ 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;
@@ -662,70 +629,8 @@ function placeTradeTip(px: number, py: number, rowCount: number): { x: number; y
return { x, y }; return { x, y };
} }
// ---------- 筹码峰指标同步不重建图表createIndicator 不支持原地改 extendData // 右侧固定留白价格轴与末根K之间的呼吸空间scrollToRealTime 以它为锚点)
// 先删后建即可;数据/开关变化走这里,保留滚动位置与用户画线) ----------
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 });
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
} else if (exists) {
chart.removeIndicator({ name: CHIPS_NAME });
restoreRightOffset();
} else {
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
}
}
// ---------- 筹码峰竖列的右侧留白管理 ----------
// klinecharts 滚动状态唯一变量是 diff最后一根K距绘图区右缘的 bar 数),
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
// “视口拽回实时位并留 px 空白”。因此只在「右区」末根K在视野内getVisibleRange().to
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance会把滚动限制切到
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
const BASE_RIGHT_PX = 28; const BASE_RIGHT_PX = 28;
let syncingRightOffset = false; // 重入守卫set 会同步派发 onVisibleRangeChange
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量bar 最宽 50px → 峰值 ~182px */
function targetRightPx(): number {
if (!props.showChips) return BASE_RIGHT_PX;
const bar = chart?.getBarSpace().bar || 10;
return Math.round(CHIPS_COL_PX + bar + 12);
}
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
function syncRightOffset(): void {
if (!chart || syncingRightOffset || !props.showChips) return;
const list = chart.getDataList();
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
const target = targetRightPx();
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
syncingRightOffset = true;
try {
chart.setOffsetRightDistance(target);
managedRightPx = target;
} finally {
syncingRightOffset = false;
}
}
/** 关筹码时一次性还原 28px仅当留白仍在我们管理范围内用户没手动拖大过 */
function restoreRightOffset(): void {
if (!chart || managedRightPx === 0) return;
const list = chart.getDataList();
const cur = chart.getOffsetRightDistance();
if (list.length && chart.getVisibleRange().to >= list.length
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
chart.setOffsetRightDistance(BASE_RIGHT_PX);
}
managedRightPx = 0;
}
function build() { function build() {
if (!container.value || props.candles.length === 0) return; if (!container.value || props.candles.length === 0) return;
@@ -819,7 +724,6 @@ function build() {
calcParams: DKX_PERIODS, extendData: { blocks: props.zhixingBlocks }, calcParams: DKX_PERIODS, extendData: { blocks: props.zhixingBlocks },
}); });
} }
if (props.showChips) syncChipsIndicator(); // 筹码峰(数据未到时 draw 直接空转,到货后由 watch 补建)
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度。 // 副图按用户顺序创建,并设置用户高度;主图吃剩余高度。
// minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致) // minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致)
@@ -852,23 +756,8 @@ function build() {
const from = (payload as { data?: { from?: unknown } }).data?.from; const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch); if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
}); });
// 右侧留白:开筹码时按竖列宽度预留scrollToRealTime 以它为锚点,先后顺序不能换) // 右侧留白scrollToRealTime 以它为锚点,先后顺序不能换)
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX); ch.setOffsetRightDistance(BASE_RIGHT_PX);
// 筹码竖列的留白跟随:缩放后 barSpace 变化onZoom 触发时新值已生效)立即补扩;
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff逐帧同步会与拖拽
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
ch.subscribeAction('onZoom', () => {
if (myEpoch !== epoch) return;
syncRightOffset();
});
ch.subscribeAction('onScroll', () => {
if (myEpoch !== epoch) return;
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
scrollReanchorTimer = setTimeout(() => {
scrollReanchorTimer = null;
if (myEpoch === epoch) syncRightOffset();
}, 150);
});
ch.scrollToRealTime(); ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后 // 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。 //init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
@@ -881,8 +770,6 @@ function build() {
function teardown() { function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; } if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
managedRightPx = 0;
if (container.value) dispose(container.value); if (container.value) dispose(container.value);
chart = null; chart = null;
hover.value = null; hover.value = null;
@@ -893,9 +780,6 @@ function teardown() {
onMounted(build); onMounted(build);
onBeforeUnmount(teardown); onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, 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

@@ -5,6 +5,7 @@ import { getMarketOverview } from '@/api/client';
import type { IndexQuote, MarketOverview as Overview } from '@/api/types'; import type { IndexQuote, MarketOverview as Overview } from '@/api/types';
import AmountHistoryChart from '@/components/AmountHistoryChart.vue'; import AmountHistoryChart from '@/components/AmountHistoryChart.vue';
import IndexKLine from '@/components/IndexKLine.vue';
// 主页大盘总览A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。 // 主页大盘总览A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
// 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。 // 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。
@@ -226,6 +227,9 @@ function hoverText(it: IndexQuote): string {
</div> </div>
</div> </div>
<!-- 上证指数 K 线///周期随用户偏好持久化 -->
<IndexKLine class="mt-4" />
<!-- 两市统计daily_info SH_MARKET(主板A+科创+B) + SZ_MARKET 汇总 --> <!-- 两市统计daily_info SH_MARKET(主板A+科创+B) + SZ_MARKET 汇总 -->
<div <div
v-if="overview.stats" v-if="overview.stats"

View File

@@ -1,15 +1,14 @@
<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, getStockChips, getStockPreview, getTrades, getWatchlist as getWatchlistApi, addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
importTrades, removeWatchlist, importTrades, removeWatchlist,
} from '@/api/client'; } from '@/api/client';
import type { import type {
ChartLayoutPrefs, ChipsResponse, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField, ChartLayoutPrefs, 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,8 +66,11 @@ const subPanes = computed<string[]>(() => layout.value.subPanes);
const maPeriods = computed<number[]>(() => layout.value.maPeriods); 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); // 主图可选叠加BOLL / 知行):随用户偏好持久化,跨会话/跨设备记忆开关
const showZhixing = ref(false); const showBoll = computed<boolean>(() => layout.value.showBoll === true);
const showZhixing = computed<boolean>(() => layout.value.showZhixing === true);
function toggleBoll() { settings.setChartLayout({ showBoll: !showBoll.value }); }
function toggleZhixing() { settings.setChartLayout({ showZhixing: !showZhixing.value }); }
// 知行指标图例行的板块文本(通达信 HYBLOCK+' '+DYBLOCK 的近似GNBLOCK 概念板块无数据源) // 知行指标图例行的板块文本(通达信 HYBLOCK+' '+DYBLOCK 的近似GNBLOCK 概念板块无数据源)
const zhixingBlocks = computed(() => { const zhixingBlocks = computed(() => {
const info = data.value?.info; const info = data.value?.info;
@@ -76,84 +78,6 @@ const zhixingBlocks = computed(() => {
return [info.industry, info.area].filter(Boolean).join(' ') || 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;
settings.setChartLayout({ settings.setChartLayout({
@@ -664,7 +588,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors" class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'" :class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
title="主图叠加布林带" title="主图叠加布林带"
@click="showBoll = !showBoll" @click="toggleBoll"
>BOLL</button> >BOLL</button>
<!-- 知行通达信导入的自定义主图指标趋势线+多空线 --> <!-- 知行通达信导入的自定义主图指标趋势线+多空线 -->
<button <button
@@ -672,16 +596,8 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors" 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]'" :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" title="知行指标:短期趋势线 EMA(EMA(C,10),10) + 多空线 (MA14+MA28+MA57+MA114)/4"
@click="showZhixing = !showZhixing" @click="toggleZhixing"
>知行</button> >知行</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"
@@ -812,9 +728,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:show-boll="showBoll" :show-boll="showBoll"
:show-zhixing="showZhixing" :show-zhixing="showZhixing"
:zhixing-blocks="zhixingBlocks" :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"

View File

@@ -1,188 +0,0 @@
// 个股筹码峰Tushare cyq_chips / cyq_perf同花顺火焰山式右侧专用竖列
//
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
// 经 extendData 注入、纯 draw 绘制:
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】K线不透出同花顺同款
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态)
// - 分界价 = 选中K线的收盘价refClose下方为获利盘、上方为套牢盘
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白保证最新K线不被竖列盖住。
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
import type { IndicatorTemplate } from 'klinecharts';
export const CHIPS_NAME = 'pv-chips';
/** 火焰山竖列基准宽度pxDetailKLine 据此预留右侧留白draw 据此画列 */
export const CHIPS_COL_PX = 120;
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
export interface ChipsExt {
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
date?: string; // YYYYMMDD 截面日
refClose?: number | null; // 获利/套牢分界价选中K线收盘缺省时 draw 退回图表末根收盘)
winner?: number | null; // 获利比例 %
avg?: number | null; // 平均成本
costLow?: number | null; // 90% 成本下沿cost_5pct
costHigh?: number | null; // 90% 成本上沿cost_95pct
error?: string | null; // 无数据/出错的提示文案(仍要画出来,用户才知道为何空)
}
// 同花顺筹码峰配色:获利盘红、套牢盘蓝
const UP = '#FE354B';
const DOWN = '#2F7BFF';
const AVG_COLOR = '#F5C518';
const LABEL_COLOR = '#9AA0AA';
const VALUE_COLOR = '#E8EAED';
// 竖列几何:窄图下限 56px最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
const COL_MIN_PX = 56;
const COL_MAX_RATIO = 0.35;
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
const COL_PAD = 5;
/** 不透明底色(图表容器纯黑 bg-black取应用面板色左缘 1px 分隔线与 y 轴线同色 */
const COL_BG = '#101014';
const COL_BORDER = '#2A2D34';
/** 实心条透明度:不透明列内 0.85 足够“实心”,与底色留一丝层次 */
const BAR_ALPHA = 0.85;
/** 横条高度上限px极度放大时单价位占比条不至于铺满大半个竖列 */
const BAR_HEIGHT_CAP = 24;
/** 统计文字起始 y画线工具栏是 HTML 层,占顶部 ~36px文字块从 44px 起)/ 行高 */
const HEADER_TOP = 44;
const LINE_H = 13;
function fmtDate(d?: string): string {
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
}
/** 右对齐数值超宽时截断加省略号90%成本 “10.50~14.20” 这类长串的兜底) */
function fitText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) return text;
let s = text;
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) s = s.slice(0, -1);
return `${s}`;
}
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
name: CHIPS_NAME,
shortName: '筹码',
figures: [], // 纯 draw 绘制,无序列图形
calc: (d) => d.map(() => ({})),
// 返回 trueisCover跳过框架 figure 渲染(本来也没有 figures全部自己画
draw: ({ ctx, chart, indicator, bounding, yAxis }) => {
const ext = indicator.extendData;
if (!ext) return true;
// 竖列几何bounding.right 即 y 轴左缘,列贴着价格轴(预留逻辑见 DetailKLine 的 syncRightOffset
const colW = Math.max(COL_MIN_PX, Math.min(CHIPS_COL_PX, Math.floor(bounding.width * COL_MAX_RATIO)));
const colRight = bounding.right;
const colLeft = colRight - colW;
// 无数据/出错:不画底色列(空面板不遮图),错误文案画在原右上角位置
if (!ext.rows.length) {
if (ext.error) {
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(`筹码 ${ext.error}`, bounding.right - 4, bounding.top + HEADER_TOP);
}
return true;
}
// 1. 不透明底色列 + 左缘 1px 分隔线(先画,盖住网格/K线/MA/最新价虚线的右端)
ctx.fillStyle = COL_BG;
ctx.fillRect(colLeft, bounding.top, colW, bounding.height);
ctx.fillStyle = COL_BORDER;
ctx.fillRect(colLeft, bounding.top, 1, bounding.height);
const list = chart.getDataList();
// 分界价选中K线的收盘价父组件随截面注入缺省退回图表末根收盘
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
const split = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
// 2. 筹码横条:列右缘向左伸展,宽度 ∝ 占比/最大占比
// 价位升序,便于求相邻像素间距(直方图的“条高”)
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
if (maxP > 0) {
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
pitches.sort((a, b) => a - b);
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
const maxBarW = colW - COL_PAD * 2;
for (const r of rows) {
const y = yAxis.convertToPixel(r.price);
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
const w = Math.max(1, (r.percent / maxP) * maxBarW);
ctx.fillStyle = split == null ? 'rgba(154,160,170,0.85)'
: r.price <= split ? `rgba(254,53,75,${BAR_ALPHA})` : `rgba(47,123,255,${BAR_ALPHA})`;
ctx.fillRect(colRight - COL_PAD - w, y - barH / 2, w, barH);
}
}
// 3. 平均成本虚线(横贯竖列)
if (ext.avg != null) {
const y = yAxis.convertToPixel(ext.avg);
if (y >= bounding.top && y <= bounding.bottom) {
ctx.save();
ctx.strokeStyle = AVG_COLOR;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
ctx.lineTo(colRight, Math.round(y) + 0.5);
ctx.stroke();
ctx.restore();
}
}
// 4. 现价细实线(末根收盘,红/蓝随涨跌,与 y 轴最新价标签同源同色;
// 分界价本身已是横条红蓝交界,不再重复画线)
if (list.length >= 2) {
const last = list[list.length - 1];
const y = yAxis.convertToPixel(last.close);
if (y >= bounding.top && y <= bounding.bottom) {
ctx.save();
ctx.strokeStyle = last.close >= list[list.length - 2].close ? UP : DOWN;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
ctx.lineTo(colRight, Math.round(y) + 0.5);
ctx.stroke();
ctx.restore();
}
}
// 5. 统计小字块列内顶部label 左对齐 + value 右对齐,超宽截断
//列宽有限两列布局比原先的单行右对齐更能放下「90%成本 10.50~14.20」这类长串)
const w = ext.winner;
const lines: [string, string, string][] = [
['筹码', fmtDate(ext.date), VALUE_COLOR],
['获利比例', w == null ? '—' : `${w.toFixed(2)}%`, w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN],
['平均成本', ext.avg == null ? '—' : ext.avg.toFixed(2), AVG_COLOR],
['90%成本', ext.costLow == null || ext.costHigh == null ? '—'
: `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`, VALUE_COLOR],
];
ctx.font = '10px sans-serif';
ctx.textBaseline = 'top';
const labelX = colLeft + COL_PAD;
const valueX = colRight - COL_PAD;
const valueMaxW = colW - COL_PAD * 2 - 36; // 36 ≈ 左侧标签区预留宽
let ty = bounding.top + HEADER_TOP;
for (const [label, value, color] of lines) {
ctx.textAlign = 'left';
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(label, labelX, ty);
ctx.textAlign = 'right';
ctx.fillStyle = color;
ctx.fillText(fitText(ctx, value, valueMaxW), valueX, ty);
ty += LINE_H;
}
return true;
},
};

View File

@@ -2,22 +2,14 @@
// 依赖预计算序列的指标(知行)不在此处闭包数据,而是导出模板工厂、由 DetailKLine 注入 // 依赖预计算序列的指标(知行)不在此处闭包数据,而是导出模板工厂、由 DetailKLine 注入
// 按时间戳取值的访问器g(),与 pv-boll 等后端序列同款管道)。注意 g 及其闭包的 // 按时间戳取值的访问器g(),与 pv-boll 等后端序列同款管道)。注意 g 及其闭包的
// PV/allData 是组件实例级的script setup 每次挂载重建),知行模板必须随挂载重注册 // PV/allData 是组件实例级的script setup 每次挂载重建),知行模板必须随挂载重注册
// 换绑到当前实例——否则永远读首挂实例的数据,切股后画的是第一只股票的线 // 换绑到当前实例——否则永远读首挂实例的数据,切股后画的是第一只股票的线
// 筹码峰等“截面数据 + 纯 draw”指标不闭包实例数据经 extendData 注入),注册一次即可。
import { registerIndicator } from 'klinecharts'; import { registerIndicator } from 'klinecharts';
import { chipsIndicator } from './chips';
import { makeZhixingIndicator, ZHIXING_NAME, type ZhixingGetter } from './zhixing'; import { makeZhixingIndicator, ZHIXING_NAME, type ZhixingGetter } from './zhixing';
let staticRegistered = false;
export function registerCustomIndicators(get: ZhixingGetter) { export function registerCustomIndicators(get: ZhixingGetter) {
// 知行:同名重注册即替换,把 calc 换绑到本次挂载实例的 g与 DetailKLine 内 // 知行:同名重注册即替换,把 calc 换绑到本次挂载实例的 g与 DetailKLine 内
// pv-boll/pv-macd 每挂必注册同款;幂等闸曾导致知行只认第一只股票) // pv-boll/pv-macd 每挂必注册同款;幂等闸曾导致知行只认第一只股票)
registerIndicator(makeZhixingIndicator(get)); registerIndicator(makeZhixingIndicator(get));
if (staticRegistered) return;
staticRegistered = true;
registerIndicator(chipsIndicator);
} }
export { CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from './chips';
export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing'; export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing';

View File

@@ -1,7 +1,7 @@
import { computed, ref, watchEffect } from 'vue'; import { computed, ref, watchEffect } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { getPreferences, putPreferences } from '@/api/client'; import { getPreferences, putPreferences } from '@/api/client';
import type { ChartLayoutPrefs, TooltipField } from '@/api/types'; import type { ChartLayoutPrefs, Timeframe, TooltipField } from '@/api/types';
export type PriceTone = 'red-up' | 'green-up'; export type PriceTone = 'red-up' | 'green-up';
export type PriceAdjust = 'bfq' | 'qfq' | 'hfq'; export type PriceAdjust = 'bfq' | 'qfq' | 'hfq';
@@ -37,6 +37,12 @@ function normTipFields(v: unknown): TooltipField[] | undefined {
return v.filter((k): k is TooltipField => typeof k === 'string' && TIP_VALID.has(k as TooltipField)); return v.filter((k): k is TooltipField => typeof k === 'string' && TIP_VALID.has(k as TooltipField));
} }
const TF_VALID = new Set(['1d', '1w', '1M', '1y']);
/** K线周期合法化非法/缺失返回 undefined调用方回退默认值 */
function normTimeframe(v: unknown): Timeframe | undefined {
return typeof v === 'string' && TF_VALID.has(v) ? (v as Timeframe) : undefined;
}
function loadTone(): PriceTone { function loadTone(): PriceTone {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -63,6 +69,9 @@ function loadLayout(): ChartLayoutPrefs {
...v, ...v,
subHeights: v.subHeights ?? {}, subHeights: v.subHeights ?? {},
tooltipFields: normTipFields(v.tooltipFields) ?? DEFAULT_TOOLTIP_FIELDS, tooltipFields: normTipFields(v.tooltipFields) ?? DEFAULT_TOOLTIP_FIELDS,
showBoll: v.showBoll === true,
showZhixing: v.showZhixing === true,
indexTimeframe: normTimeframe(v.indexTimeframe),
}; };
} }
} }
@@ -165,7 +174,14 @@ export const useSettingsStore = defineStore('settings', () => {
if (Array.isArray(v.maPeriods) && Array.isArray(v.subPanes)) { if (Array.isArray(v.maPeriods) && Array.isArray(v.subPanes)) {
// 服务端存档早于浮层配置(无 tooltipFields时保留本地选择避免旧档案冲掉 // 服务端存档早于浮层配置(无 tooltipFields时保留本地选择避免旧档案冲掉
const tip = normTipFields(v.tooltipFields) ?? chartLayout.value.tooltipFields; const tip = normTipFields(v.tooltipFields) ?? chartLayout.value.tooltipFields;
chartLayout.value = { ...v, subHeights: v.subHeights ?? {}, tooltipFields: tip }; chartLayout.value = {
...v,
subHeights: v.subHeights ?? {},
tooltipFields: tip,
showBoll: v.showBoll === true,
showZhixing: v.showZhixing === true,
indexTimeframe: normTimeframe(v.indexTimeframe),
};
saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value)); saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value));
} }
} else { } else {