提交
This commit is contained in:
@@ -3,8 +3,19 @@
|
||||
GET /api/health 健康检查
|
||||
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
|
||||
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
|
||||
GET /api/market/overview 主页大盘总览(A 股/港美指数 + 两市市值成交统计)
|
||||
GET /api/stocks/{ts_code}/company 个股公司简介(tushare stock_company 按需懒加载)
|
||||
GET /api/stocks/{ts_code}/finance 个股财务数据(fina_indicator+三大报表关键值,近五年懒加载)
|
||||
GET /api/stocks/{ts_code}/dividends 个股分红送股(tushare dividend 全历史懒加载)
|
||||
GET /api/stocks/{ts_code}/reference/{kind} 个股参考数据(top10/质押/回购/解禁/大宗/股东等 11 类懒加载)
|
||||
GET /api/etfs 全市场场内 ETF 列表(东财快照 + candles 最新行情)
|
||||
POST /api/etf/sync 启动全市场 ETF 数据同步(后台任务)
|
||||
GET /api/etf/sync/status ETF 同步任务状态与数据实况
|
||||
GET /api/market/overview 主页大盘总览(中美指数 + 两市市值成交统计)
|
||||
GET /api/market/index-candles 上证指数全量 K 线(1d/1w/1M/1y 聚合)
|
||||
GET /api/market/global-indexes 国际指数卡片列表(index_global 21 指数)
|
||||
GET /api/market/indexes/{code} 指数详情(行情 + index_basic + 估值)
|
||||
GET /api/market/indexes/{code}/candles 白名单指数全量 K 线(1d/1w/1M/1y 聚合)
|
||||
GET /api/market/indexes/{code}/weights 指数成分股权重(index_weight 最近月度)
|
||||
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
|
||||
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
|
||||
POST /api/screener/sync 启动全市场数据同步(后台任务)
|
||||
@@ -31,11 +42,17 @@ from .auth import require_user
|
||||
from .backtest.events import EventEngineError, run_event_backtest
|
||||
from .backtest.strategies import build_strategy
|
||||
from .config import settings
|
||||
from .data import company as company_mod
|
||||
from .data import dividend as dividend_mod
|
||||
from .data import finance as finance_mod
|
||||
from .data import reference as reference_mod
|
||||
from .data import fetcher, repository
|
||||
from .data import etf_sync as etf_sync_mod
|
||||
from .data import index_global as index_mod
|
||||
from .data.aggregation import bars_per_year, resample_bars
|
||||
from .data.index_series import SH_INDEX, get_index_daily
|
||||
from .data.market_overview import MarketOverviewError, fetch_overview
|
||||
from .data.symbols import plain_code
|
||||
from .data.symbols import is_etf_symbol, plain_code, to_ts_code
|
||||
from .db import async_session, get_session
|
||||
from .domain import Bar
|
||||
from . import indicators as ind
|
||||
@@ -58,6 +75,17 @@ from .schemas import (
|
||||
EquityPoint,
|
||||
EventBacktestRequest,
|
||||
EventBacktestResponse,
|
||||
EtfListItemOut,
|
||||
EtfListResponse,
|
||||
EtfSyncRequest,
|
||||
EtfSyncStatus,
|
||||
GlobalIndexListResponse,
|
||||
IndexBasicOut,
|
||||
IndexDetailResponse,
|
||||
IndexQuoteBriefOut,
|
||||
IndexValuationPointOut,
|
||||
IndexWeightItemOut,
|
||||
IndexWeightsResponse,
|
||||
IndicatorOut,
|
||||
MarketOverviewResponse,
|
||||
MetricsOut,
|
||||
@@ -72,6 +100,10 @@ from .schemas import (
|
||||
ScreenerSyncRequest,
|
||||
ScreenerSyncStatus,
|
||||
SignalOut,
|
||||
StockCompanyOut,
|
||||
StockDividendOut,
|
||||
StockFinanceOut,
|
||||
StockReferenceOut,
|
||||
StockListItemOut,
|
||||
StockListResponse,
|
||||
StockFacetsResponse,
|
||||
@@ -137,20 +169,34 @@ _FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
|
||||
_INDEX_TIMEFRAMES = ("1d", "1w", "1M", "1y")
|
||||
|
||||
# 信息卡一条 SQL 拿全:stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot
|
||||
# (LATERAL 单条替换原两条查询,语义不变:target 为 NULL 时全按最新日兜底)
|
||||
# (LATERAL 单条替换原两条查询,语义不变:target 为 NULL 时全按最新日兜底)。
|
||||
# ETF 走 etf_basic 分支(代码前缀与股票不重叠,两分支至多一个命中):
|
||||
# 名称/上市日来自表内,市值(元)换算成万元与快照口径一致,无 PE/PB。
|
||||
_INFO_SQL = text(
|
||||
"""
|
||||
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.area, sb.market, sb.list_date,
|
||||
ds.turnover_rate, ds.pe_ttm, ds.pb, ds.total_mv, ds.circ_mv
|
||||
FROM stock_basic sb
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT turnover_rate, pe_ttm, pb, total_mv, circ_mv
|
||||
FROM daily_snapshot
|
||||
WHERE ts_code = sb.ts_code
|
||||
ORDER BY (trade_date = cast(:target AS timestamp)) DESC, trade_date DESC
|
||||
LIMIT 1
|
||||
) ds ON true
|
||||
WHERE sb.ts_code = :code
|
||||
SELECT ts_code, symbol, name, industry, area, market, list_date,
|
||||
turnover_rate, pe_ttm, pb, total_mv, circ_mv
|
||||
FROM (
|
||||
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.area, sb.market, sb.list_date,
|
||||
ds.turnover_rate, ds.pe_ttm, ds.pb, ds.total_mv, ds.circ_mv
|
||||
FROM stock_basic sb
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT turnover_rate, pe_ttm, pb, total_mv, circ_mv
|
||||
FROM daily_snapshot
|
||||
WHERE ts_code = sb.ts_code
|
||||
ORDER BY (trade_date = cast(:target AS timestamp)) DESC, trade_date DESC
|
||||
LIMIT 1
|
||||
) ds ON true
|
||||
WHERE sb.ts_code = :code
|
||||
UNION ALL
|
||||
SELECT eb.ts_code, eb.symbol, eb.name, NULL, NULL,
|
||||
CASE eb.exchange WHEN 'SH' THEN '沪市' ELSE '深市' END, eb.list_date,
|
||||
eb.turnover_rate, NULL, NULL,
|
||||
eb.total_mv / 10000.0, eb.circ_mv / 10000.0
|
||||
FROM etf_basic eb
|
||||
WHERE eb.ts_code = :code
|
||||
) t
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -389,6 +435,198 @@ async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response
|
||||
return Response(content=raw, media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/stocks/{ts_code}/company", response_model=StockCompanyOut)
|
||||
async def stock_company_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockCompanyOut:
|
||||
"""公司简介:库内有新鲜行直返;否则锁内单查 tushare(stock_company)并 upsert(行即缓存,
|
||||
30 天新鲜度,无此股写墓碑负缓存)。ETF 前置短路;确认无数据 404;tushare 失败且
|
||||
无旧行可降级时 503(有旧行则在数据层降级返回旧行)。"""
|
||||
code = ts_code.strip().upper()
|
||||
if "." not in code:
|
||||
code = to_ts_code(code) # 防御:兼容 6 位裸代码
|
||||
if is_etf_symbol(code):
|
||||
raise HTTPException(status_code=404, detail="ETF 无公司简介")
|
||||
try:
|
||||
row = await company_mod.get_company(session, code)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="tushare 公司简介拉取失败,请稍后重试")
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"无公司信息: {code}")
|
||||
return StockCompanyOut(**row)
|
||||
|
||||
|
||||
@router.get("/stocks/{ts_code}/finance", response_model=StockFinanceOut)
|
||||
async def stock_finance_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockFinanceOut:
|
||||
"""财务数据(近五年,按报告期倒序):库内新鲜直返;否则锁内拉 tushare 四源
|
||||
(fina_indicator/income/balancesheet/cashflow)合并 upsert(7 天新鲜度,无数据写墓碑)。
|
||||
ETF 前置短路;确认无数据 404;tushare 四源全失败且无旧行可降级时 503。"""
|
||||
code = ts_code.strip().upper()
|
||||
if "." not in code:
|
||||
code = to_ts_code(code)
|
||||
if is_etf_symbol(code):
|
||||
raise HTTPException(status_code=404, detail="ETF 无财务数据")
|
||||
try:
|
||||
rows = await finance_mod.get_finance(session, code)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="tushare 财务数据拉取失败,请稍后重试")
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail=f"无财务数据: {code}")
|
||||
return StockFinanceOut(ts_code=code, records=rows)
|
||||
|
||||
|
||||
@router.get("/stocks/{ts_code}/dividends", response_model=StockDividendOut)
|
||||
async def stock_dividend_info(ts_code: str, session: AsyncSession = Depends(get_session)) -> StockDividendOut:
|
||||
"""分红送股(全历史,按分红年度倒序):库内新鲜直返;否则锁内拉 tushare dividend
|
||||
全量替换(7 天新鲜度,无分红写墓碑,空列表是正常返回)。
|
||||
ETF 前置短路;tushare 失败且无旧行可降级时 503。"""
|
||||
code = ts_code.strip().upper()
|
||||
if "." not in code:
|
||||
code = to_ts_code(code)
|
||||
if is_etf_symbol(code):
|
||||
raise HTTPException(status_code=404, detail="ETF 无分红数据")
|
||||
try:
|
||||
rows = await dividend_mod.get_dividends(session, code)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="tushare 分红数据拉取失败,请稍后重试")
|
||||
return StockDividendOut(ts_code=code, records=rows)
|
||||
|
||||
|
||||
@router.get("/stocks/{ts_code}/reference/{kind}", response_model=StockReferenceOut)
|
||||
async def stock_reference_info(ts_code: str, kind: str, session: AsyncSession = Depends(get_session)) -> StockReferenceOut:
|
||||
"""参考数据(11 类,kind 白名单见 reference.REFERENCE_KINDS):单股单分类 JSON 快照
|
||||
懒加载(7 天新鲜度,无数据写墓碑,空 records 是正常返回)。repurchase 为全市场
|
||||
按月回填的特殊管道:首次触发后台回填近 24 个月(本次可能返回空,稍后再看)。
|
||||
ETF 前置短路;未知 kind 404;tushare 失败且无旧行可降级时 503。"""
|
||||
code = ts_code.strip().upper()
|
||||
if "." not in code:
|
||||
code = to_ts_code(code)
|
||||
if is_etf_symbol(code):
|
||||
raise HTTPException(status_code=404, detail="ETF 无参考数据")
|
||||
if kind not in reference_mod.REFERENCE_KINDS:
|
||||
raise HTTPException(status_code=404, detail=f"未知参考数据分类: {kind}")
|
||||
try:
|
||||
rows = await reference_mod.get_reference(session, code, kind)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="tushare 参考数据拉取失败,请稍后重试")
|
||||
return StockReferenceOut(ts_code=code, kind=kind, records=rows)
|
||||
|
||||
|
||||
# ---------- ETF 列表(全市场浏览;行情走 candles 底座,规模/换手走东财快照) ----------
|
||||
# 与 /stocks 不同:成交额来自 candles 最新 bar(LATERAL),必须在分页前 join 才能参与
|
||||
# 排序 —— ETF 全市场仅 ~1100 行,3 个索引探测/行 也就几 ms,可以承受。
|
||||
# 排序列白名单(键→表达式);order_by 由白名单拼接进模板,不接收用户原文。
|
||||
_ETFS_SORTS = {
|
||||
"symbol": "eb.symbol",
|
||||
"close": "c.close",
|
||||
"pct_chg": "pct_chg",
|
||||
"amount": "c.amount",
|
||||
"total_mv": "eb.total_mv",
|
||||
"circ_mv": "eb.circ_mv",
|
||||
"turnover_rate": "eb.turnover_rate",
|
||||
}
|
||||
|
||||
_ETFS_SQL_TMPL = """
|
||||
SELECT eb.ts_code, eb.symbol, eb.name, eb.exchange, eb.list_date,
|
||||
(w.id IS NOT NULL) AS watched,
|
||||
eb.turnover_rate,
|
||||
round((eb.total_mv / 100000000.0)::numeric, 2) AS total_mv,
|
||||
round((eb.circ_mv / 100000000.0)::numeric, 2) AS circ_mv,
|
||||
c.close AS close, prev.close AS prev_close, c.ts AS last_ts,
|
||||
CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0
|
||||
THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg,
|
||||
round((c.amount / 100000000.0)::numeric, 2) AS amount
|
||||
FROM etf_basic eb
|
||||
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT close, ts, amount FROM candles
|
||||
WHERE symbol = eb.symbol AND timeframe = '1d'
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
) c ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT close FROM candles
|
||||
WHERE symbol = eb.symbol AND timeframe = '1d' AND ts < c.ts
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
) prev ON c.ts IS NOT NULL
|
||||
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
|
||||
AND (:exchange = '' OR eb.exchange = :exchange)
|
||||
AND (:watched_only = false OR w.id IS NOT NULL)
|
||||
ORDER BY {order_by}
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
|
||||
_ETFS_COUNT_SQL = text("""
|
||||
SELECT count(*) FROM etf_basic eb
|
||||
LEFT JOIN watchlist_items w ON w.ts_code = eb.ts_code AND w.user_id = :uid
|
||||
WHERE (:search = '' OR eb.symbol LIKE :psearch OR eb.name LIKE :psearch)
|
||||
AND (:exchange = '' OR eb.exchange = :exchange)
|
||||
AND (:watched_only = false OR w.id IS NOT NULL)
|
||||
""")
|
||||
|
||||
|
||||
def _etfs_sql(sort: str, order: str) -> TextClause:
|
||||
col = _ETFS_SORTS.get(sort, _ETFS_SORTS["symbol"])
|
||||
direction = "DESC" if order == "desc" else "ASC"
|
||||
nulls = " NULLS LAST" if col != "eb.symbol" else "" # 无行情/无快照的排最后
|
||||
return text(_ETFS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
|
||||
|
||||
|
||||
@router.get("/etfs", response_model=EtfListResponse)
|
||||
async def list_etfs(
|
||||
search: str = "",
|
||||
exchange: str = "",
|
||||
watched_only: bool = False,
|
||||
sort: str = "symbol",
|
||||
order: str = "asc",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
) -> Response:
|
||||
"""全市场场内 ETF 列表:etf_basic 名称/规模(东财快照)+ candles 最新收盘/涨跌幅/成交额。
|
||||
exchange ∈ {SH, SZ}(空 = 全部);sort ∈ {symbol,close,pct_chg,amount,total_mv,circ_mv,
|
||||
turnover_rate}(白名单,其他值回落 symbol),order ∈ asc/desc;快照/行情列排序时
|
||||
缺失值恒排末尾。缓存:按「用户自选版本 + etf 版本 + 查询参数」缓存整页,
|
||||
ETF 同步完成(bump ver:etf / ver:candles)或自选增删即失效。
|
||||
"""
|
||||
search = search.strip()
|
||||
sort = sort if sort in _ETFS_SORTS else "symbol"
|
||||
order = "desc" if order.lower() == "desc" else "asc"
|
||||
limit = max(1, min(limit, 500))
|
||||
offset = max(0, offset)
|
||||
key = (
|
||||
f"etfsj:u{user.id}"
|
||||
f":v{await cache.get_version(f'watchlist:{user.id}')}"
|
||||
f":v{await cache.get_version('etf')}"
|
||||
f":{cache.digest(search, exchange, watched_only, sort, order, limit, offset)}"
|
||||
)
|
||||
cached = await _cached_json_response(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
params = {
|
||||
"search": search, "psearch": f"%{search}%",
|
||||
"exchange": exchange.upper(), "watched_only": watched_only,
|
||||
"uid": user.id, "limit": limit, "offset": offset,
|
||||
}
|
||||
total = (await session.execute(_ETFS_COUNT_SQL, params)).scalar_one()
|
||||
rows = (await session.execute(_etfs_sql(sort, order), params)).mappings().all()
|
||||
resp = EtfListResponse(total=total, items=[EtfListItemOut(**r) for r in rows])
|
||||
raw = _raw_json(resp)
|
||||
cache.local_set(key, raw, ttl=min(120, settings.stocks_cache_ttl))
|
||||
asyncio.create_task(cache.cache_set(key, raw, ttl=settings.stocks_cache_ttl))
|
||||
return Response(content=raw, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/etf/sync", response_model=EtfSyncStatus)
|
||||
async def etf_sync_start(req: EtfSyncRequest) -> EtfSyncStatus:
|
||||
"""启动全市场 ETF 同步(后台任务:东财快照 -> etf_basic,逐只日线 -> candles)。"""
|
||||
return EtfSyncStatus(**await etf_sync_mod.start_sync(full=req.full))
|
||||
|
||||
|
||||
@router.get("/etf/sync/status", response_model=EtfSyncStatus)
|
||||
async def etf_sync_status(session: AsyncSession = Depends(get_session)) -> EtfSyncStatus:
|
||||
"""ETF 同步任务状态与数据实况(ETF 数 / 最新交易日)。"""
|
||||
return EtfSyncStatus(**await etf_sync_mod.get_status(session))
|
||||
|
||||
|
||||
@router.get("/market/overview", response_model=MarketOverviewResponse)
|
||||
async def get_market_overview(session: AsyncSession = Depends(get_session)) -> MarketOverviewResponse:
|
||||
"""主页大盘总览:A 股 + 港美指数实时价(腾讯)叠加收盘历史走势(tushare),
|
||||
@@ -437,6 +675,113 @@ async def get_index_candles(timeframe: str = "1d") -> Response:
|
||||
return Response(content=raw, media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/market/global-indexes", response_model=GlobalIndexListResponse)
|
||||
async def get_global_indexes() -> GlobalIndexListResponse:
|
||||
"""国际指数卡片列表(index_global 21 个指数最新收盘 + 45 日 spark,SWR 缓存)。"""
|
||||
try:
|
||||
data = await index_mod.fetch_global_list()
|
||||
except index_mod.GlobalIndexError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
payload = dict(data)
|
||||
payload["updated_at"] = payload.pop("fetched_at")
|
||||
return GlobalIndexListResponse(**payload)
|
||||
|
||||
|
||||
def _index_or_404(code: str) -> str:
|
||||
"""详情/K线/权重接口只放行白名单内的指数 code。"""
|
||||
if not index_mod.ensure_known(code):
|
||||
raise HTTPException(status_code=404, detail=f"不支持的指数代码: {code}")
|
||||
return code
|
||||
|
||||
|
||||
@router.get("/market/indexes/{code}", response_model=IndexDetailResponse)
|
||||
async def get_index_detail(code: str) -> IndexDetailResponse:
|
||||
"""指数详情聚合:最新行情(收盘口径)+ 基本信息(国内 index_basic / 国际静态表)
|
||||
+ 估值指标(index_dailybasic,仅部分国内指数)。各层自带缓存,直接组装。"""
|
||||
code = _index_or_404(code)
|
||||
if index_mod.is_cn_index(code):
|
||||
name, region = index_mod.CN_INDEXES.get(code, code), "cn"
|
||||
else:
|
||||
g = index_mod.GLOBAL_META[code]
|
||||
name, region = g["name"], g["region"]
|
||||
|
||||
try:
|
||||
quote = await index_mod.fetch_index_quote(code)
|
||||
except index_mod.GlobalIndexError as e:
|
||||
raise HTTPException(status_code=502, detail=f"指数行情获取失败: {e}") from e
|
||||
|
||||
basic_raw = await index_mod.get_index_basic(code)
|
||||
basic = IndexBasicOut(**basic_raw) if basic_raw else None
|
||||
|
||||
valuation_rows = await index_mod.get_index_valuation(code)
|
||||
valuation = IndexValuationPointOut(**valuation_rows[-1]) if valuation_rows else None
|
||||
history = [IndexValuationPointOut(**r) for r in valuation_rows]
|
||||
|
||||
return IndexDetailResponse(
|
||||
code=code, name=name, region=region,
|
||||
quote=IndexQuoteBriefOut(**quote),
|
||||
basic=basic, valuation=valuation, valuation_history=history,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/market/indexes/{code}/candles", response_model=list[CandleOut])
|
||||
async def get_any_index_candles(code: str, timeframe: str = "1d") -> Response:
|
||||
"""白名单指数全量 K 线(国内 index_daily / 国际 index_global),1d/1w/1M/1y 聚合。
|
||||
收盘口径,历史不可变、TTL 兜到当日更新(与首页上证 K 线同款缓存策略)。"""
|
||||
code = _index_or_404(code)
|
||||
if timeframe not in _INDEX_TIMEFRAMES:
|
||||
raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(_INDEX_TIMEFRAMES)}")
|
||||
key = f"idxck:{cache.digest('idxk2', code, timeframe)}"
|
||||
cached = await _cached_json_response(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
bars = resample_bars(await index_mod.get_index_bars(code), 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
|
||||
]
|
||||
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.get("/market/indexes/{code}/weights", response_model=IndexWeightsResponse)
|
||||
async def get_index_weights(
|
||||
code: str,
|
||||
limit: int = 50,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> IndexWeightsResponse:
|
||||
"""指数成分股权重(index_weight 最近月度快照,按权重降序取前 limit)。
|
||||
仅国内指数有数据;成分股名称从本地 stock_basic 回填。"""
|
||||
code = _index_or_404(code)
|
||||
limit = max(1, min(limit, 300))
|
||||
data = await index_mod.get_index_weights(code)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"该指数暂无成分权重数据: {code}")
|
||||
|
||||
items = data["items"][:limit]
|
||||
codes = [it["con_code"] for it in items]
|
||||
names: dict[str, str] = {}
|
||||
if codes:
|
||||
try:
|
||||
rows = await session.execute(
|
||||
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.ts_code.in_(codes))
|
||||
)
|
||||
names = {r[0]: r[1] for r in rows.all()}
|
||||
except Exception: # noqa: BLE001 —— 名称缺失不阻塞权重展示
|
||||
pass
|
||||
return IndexWeightsResponse(
|
||||
trade_date=data["trade_date"], total=data["total"],
|
||||
items=[IndexWeightItemOut(con_code=c, name=names.get(c), weight=w)
|
||||
for c, w in ((it["con_code"], it["weight"]) for it in items)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResponse)
|
||||
async def backtest(
|
||||
req: BacktestRequest,
|
||||
@@ -468,6 +813,7 @@ async def backtest(
|
||||
initial_cash=req.initial_cash,
|
||||
fast_mode=req.fast_mode,
|
||||
bars_per_year=bars_per_year(req.timeframe),
|
||||
is_fund=is_etf_symbol(req.symbol), # ETF 免印花税/过户费
|
||||
)
|
||||
result = run_backtest(bars, strategy, cfg)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class PaperBroker:
|
||||
schedule: CostSchedule = field(default_factory=lambda: DEFAULT)
|
||||
enable_costs: bool = True
|
||||
enable_t_plus_1: bool = True
|
||||
is_fund: bool = False # 场内基金(ETF):免印花税/过户费,仅佣金
|
||||
|
||||
cash: float = field(init=False)
|
||||
holdings: float = 0.0 # 可卖数量
|
||||
@@ -49,7 +50,7 @@ class PaperBroker:
|
||||
return None
|
||||
rate = (
|
||||
self.schedule.commission_rate
|
||||
+ self.schedule.transfer_fee_rate
|
||||
+ (0.0 if self.is_fund else self.schedule.transfer_fee_rate)
|
||||
+ self.schedule.slippage_rate
|
||||
) if self.enable_costs else 0.0
|
||||
affordable_qty = self.cash / (price * (1 + rate))
|
||||
@@ -67,7 +68,7 @@ class PaperBroker:
|
||||
|
||||
def _execute_buy(self, ts, price: float, qty: int) -> Fill:
|
||||
if self.enable_costs:
|
||||
fp, comm, tf = buy_cost(price, qty, self.schedule)
|
||||
fp, comm, tf = buy_cost(price, qty, self.schedule, is_fund=self.is_fund)
|
||||
else:
|
||||
fp, comm, tf = price, 0.0, 0.0
|
||||
cost = fp * qty + comm + tf
|
||||
@@ -85,7 +86,7 @@ class PaperBroker:
|
||||
|
||||
def _execute_sell(self, ts, price: float, qty: int) -> Fill:
|
||||
if self.enable_costs:
|
||||
fp, comm, tf, sd = sell_cost(price, qty, self.schedule)
|
||||
fp, comm, tf, sd = sell_cost(price, qty, self.schedule, is_fund=self.is_fund)
|
||||
else:
|
||||
fp, comm, tf, sd = price, 0.0, 0.0, 0.0
|
||||
proceeds = fp * qty - comm - tf - sd
|
||||
|
||||
@@ -24,6 +24,7 @@ class BacktestConfig:
|
||||
initial_cash: float = 1000000.0
|
||||
fast_mode: bool = False # True: 关 T+1/费用,交互试探
|
||||
bars_per_year: int = 252 # 日线 252;分钟级另算
|
||||
is_fund: bool = False # 场内基金(ETF):免印花税/过户费
|
||||
|
||||
|
||||
def run_backtest(bars: list[Bar], strategy, cfg: BacktestConfig | None = None) -> dict:
|
||||
@@ -45,6 +46,7 @@ def run_backtest(bars: list[Bar], strategy, cfg: BacktestConfig | None = None) -
|
||||
initial_cash=cfg.initial_cash,
|
||||
enable_costs=not cfg.fast_mode,
|
||||
enable_t_plus_1=not cfg.fast_mode,
|
||||
is_fund=cfg.is_fund,
|
||||
)
|
||||
|
||||
equity_values = []
|
||||
|
||||
@@ -28,20 +28,28 @@ class CostSchedule:
|
||||
DEFAULT = CostSchedule()
|
||||
|
||||
|
||||
def buy_cost(price: float, qty: float, sch: CostSchedule = DEFAULT) -> tuple[float, float, float]:
|
||||
"""买入成本。返回 (成交价, 佣金, 过户费)。买入无印花税。"""
|
||||
def buy_cost(price: float, qty: float, sch: CostSchedule = DEFAULT,
|
||||
is_fund: bool = False) -> tuple[float, float, float]:
|
||||
"""买入成本。返回 (成交价, 佣金, 过户费)。买入无印花税。
|
||||
|
||||
is_fund=True 为场内基金(ETF):免过户费,仅佣金 + 滑点。
|
||||
"""
|
||||
fill_price = price * (1 + sch.slippage_rate)
|
||||
gross = fill_price * qty
|
||||
commission = max(gross * sch.commission_rate, sch.commission_min)
|
||||
transfer_fee = gross * sch.transfer_fee_rate
|
||||
transfer_fee = 0.0 if is_fund else gross * sch.transfer_fee_rate
|
||||
return fill_price, commission, transfer_fee
|
||||
|
||||
|
||||
def sell_cost(price: float, qty: float, sch: CostSchedule = DEFAULT) -> tuple[float, float, float, float]:
|
||||
"""卖出成本。返回 (成交价, 佣金, 过户费, 印花税)。"""
|
||||
def sell_cost(price: float, qty: float, sch: CostSchedule = DEFAULT,
|
||||
is_fund: bool = False) -> tuple[float, float, float, float]:
|
||||
"""卖出成本。返回 (成交价, 佣金, 过户费, 印花税)。
|
||||
|
||||
is_fund=True 为场内基金(ETF):免印花税、免过户费(现行规则),仅佣金 + 滑点。
|
||||
"""
|
||||
fill_price = price * (1 - sch.slippage_rate)
|
||||
gross = fill_price * qty
|
||||
commission = max(gross * sch.commission_rate, sch.commission_min)
|
||||
transfer_fee = gross * sch.transfer_fee_rate
|
||||
stamp_duty = gross * sch.stamp_duty_rate
|
||||
transfer_fee = 0.0 if is_fund else gross * sch.transfer_fee_rate
|
||||
stamp_duty = 0.0 if is_fund else gross * sch.stamp_duty_rate
|
||||
return fill_price, commission, transfer_fee, stamp_duty
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -16,6 +17,7 @@ from ..config import settings
|
||||
from ..domain import Bar
|
||||
from ..models import Candle
|
||||
from . import akshare_provider, tushare_provider
|
||||
from .symbols import is_etf_symbol, to_ts_code
|
||||
|
||||
DEFAULT_START = settings.data_default_start or "20200101"
|
||||
|
||||
@@ -74,19 +76,19 @@ async def sync_symbol(
|
||||
# 增量:从缓存最后一根当天开始(重叠一天重新拉取,容忍数据源漏行/盘后修订)
|
||||
start = last_ts.strftime("%Y%m%d")
|
||||
start = start or DEFAULT_START
|
||||
errors: list[str] = []
|
||||
bars: list[Bar] = []
|
||||
used = None
|
||||
|
||||
for name, fn in _providers(source):
|
||||
# ETF 走 Tushare fund_daily(quicksync 镜像可用;与股票同源同控频),
|
||||
# 按ts_code 增量拉取,未收盘当日数据未生成时自然返回空。
|
||||
if is_etf_symbol(code):
|
||||
errors: list[str] = []
|
||||
try:
|
||||
# tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环;
|
||||
# adjust=None -> 不复权(复权在读取时按 adj_factor 换算)
|
||||
bars = await asyncio.to_thread(fn, code, start, end, None)
|
||||
used = name
|
||||
break
|
||||
bars = await asyncio.to_thread(_fetch_etf_daily, code, start, end)
|
||||
used = "tushare"
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{name}: {e}")
|
||||
errors.append(f"tushare: {e}")
|
||||
bars = []
|
||||
else:
|
||||
bars, used, errors = await _fetch_stock(code, start, end, source)
|
||||
|
||||
if not bars:
|
||||
if last_ts is not None:
|
||||
@@ -112,3 +114,47 @@ async def sync_symbol(
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return {"symbol": code, "bars": len(bars), "source": used}
|
||||
|
||||
|
||||
def _fetch_etf_daily(code: str, start: str | None, end: str | None) -> list[Bar]:
|
||||
"""Tushare fund_daily 按 ts_code 拉 ETF 日线(同步网络 IO,to_thread 调用)。
|
||||
单位沿用 Tushare:vol 手、amount 千元,此处换算为 股/元。"""
|
||||
from .tushare_provider import get_pro
|
||||
|
||||
pro = get_pro()
|
||||
df = pro.fund_daily(ts_code=to_ts_code(code), start_date=start, end_date=end)
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError(f"Tushare 无数据: {to_ts_code(code)}")
|
||||
df = df.sort_values("trade_date")
|
||||
bars: list[Bar] = []
|
||||
for _, r in df.iterrows():
|
||||
amt = r.get("amount")
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=datetime.strptime(str(r["trade_date"]), "%Y%m%d"),
|
||||
open=float(r["open"]), high=float(r["high"]),
|
||||
low=float(r["low"]), close=float(r["close"]),
|
||||
volume=float(r["vol"]) * 100.0, # 手 -> 份
|
||||
amount=float(amt) * 1000.0 if amt is not None and amt == amt else None, # 千元 -> 元
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
|
||||
async def _fetch_stock(code: str, start: str, end: str | None, source: str):
|
||||
"""Tushare 主 -> AKShare 兜底拉股票日线(不复权)。"""
|
||||
errors: list[str] = []
|
||||
bars: list[Bar] = []
|
||||
used = None
|
||||
|
||||
for name, fn in _providers(source):
|
||||
try:
|
||||
# tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环;
|
||||
# adjust=None -> 不复权(复权在读取时按 adj_factor 换算)
|
||||
bars = await asyncio.to_thread(fn, code, start, end, None)
|
||||
used = name
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{name}: {e}")
|
||||
|
||||
return bars, used, errors
|
||||
|
||||
@@ -28,14 +28,12 @@ from .. import cache
|
||||
from ..config import settings
|
||||
|
||||
# (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序
|
||||
# 标普500 腾讯符号是 s_usINX(不是 s_usSPX);恒生科技是 s_hkHSTECH(不是 HSTECH)
|
||||
# 首页聚焦中美(港股/国际指数在 /indexes 国际指数页);标普500 腾讯符号是 s_usINX(不是 s_usSPX)
|
||||
MARKET_INDEXES: list[tuple[str, str, str, str]] = [
|
||||
("000001.SH", "上证指数", "cn", "s_sh000001"),
|
||||
("399001.SZ", "深证成指", "cn", "s_sz399001"),
|
||||
("399006.SZ", "创业板指", "cn", "s_sz399006"),
|
||||
("000688.SH", "科创50", "cn", "s_sh000688"),
|
||||
("HSI", "恒生指数", "hk", "s_hkHSI"),
|
||||
("HKTECH", "恒生科技", "hk", "s_hkHSTECH"),
|
||||
("DJI", "道琼斯", "us", "s_usDJI"),
|
||||
("IXIC", "纳斯达克", "us", "s_usIXIC"),
|
||||
("SPX", "标普500", "us", "s_usINX"),
|
||||
@@ -49,7 +47,7 @@ _TENCENT_URL = "http://qt.gtimg.cn/q=" + ",".join([*_TENCENT_MAP.values(), _TENC
|
||||
_SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
|
||||
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark)
|
||||
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频
|
||||
_EOD_KEY = "market_overview:eod:v2" # v2:与旧整包缓存 v1 的 payload 形状不同,天然隔离
|
||||
_EOD_KEY = "market_overview:eod:v3" # v3:首页列表移除港股(移入国际指数页),与 v2 隔离
|
||||
_AMOUNT_HIST_CAL_DAYS = 190 # 成交额历史的日历日窗口(≈128 交易日)
|
||||
_AMOUNT_HIST_BARS = 120 # 输出的柱数(取尾部)
|
||||
_LIVE_FAIL_TTL = 15.0 # live 层失败负缓存(秒)
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
"""A 股代码归一化。支持 6 位纯数字或带交易所后缀(000001 / 000001.SZ)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
# 场内 ETF 代码前缀(与股票区间不重叠):沪 51/56/58 开头、深 159 开头。
|
||||
# 50/57/16 开头是 LOF,不在此列。
|
||||
_ETF_PREFIXES = ("51", "56", "58", "159")
|
||||
|
||||
|
||||
def plain_code(code: str) -> str:
|
||||
"""000001.SZ -> 000001"""
|
||||
return code.strip().upper().split(".")[0]
|
||||
|
||||
|
||||
def is_etf_symbol(code: str) -> bool:
|
||||
"""按 6 位纯代码前缀判断是否场内 ETF(fetcher 据此路由到东财数据源)。"""
|
||||
c = plain_code(code)
|
||||
return len(c) == 6 and c.startswith(_ETF_PREFIXES)
|
||||
|
||||
|
||||
def to_ts_code(code: str) -> str:
|
||||
"""转 Tushare ts_code(带交易所后缀)。"""
|
||||
c = code.strip().upper()
|
||||
if "." in c:
|
||||
return c
|
||||
c = plain_code(c)
|
||||
# 沪市:60xxxx 主板、68xxxx 科创、9xxxxx B 股
|
||||
if c.startswith(("60", "68", "9")):
|
||||
# 沪市:60xxxx 主板、68xxxx 科创、9xxxxx B 股、5xxxxx 基金(51/56/58 场内 ETF)
|
||||
if c.startswith(("60", "68", "9", "51", "56", "58")):
|
||||
return c + ".SH"
|
||||
# 深市:00xxxx 主板/中小、30xxxx 创业、20xxxx B 股
|
||||
if c.startswith(("00", "30", "20")):
|
||||
# 深市:00xxxx 主板/中小、30xxxx 创业、20xxxx B 股、159xxx 场内 ETF
|
||||
if c.startswith(("00", "30", "20", "159")):
|
||||
return c + ".SZ"
|
||||
# 北交所:8xxxxx / 4xxxxx
|
||||
if c.startswith(("8", "4")):
|
||||
|
||||
@@ -79,6 +79,163 @@ class StockBasic(Base):
|
||||
delist_date: Mapped[str | None] = mapped_column(String(8))
|
||||
|
||||
|
||||
class EtfBasic(Base):
|
||||
"""场内 ETF 列表(东财全市场快照;ETF 同步任务维护,行情另走 candles 底座)。
|
||||
|
||||
ts_code 如 510300.SH;symbol 为 6 位纯数字,与 candles.symbol 同口径。
|
||||
代码区间与股票不冲突:沪 51/56/58 开头、深 159 开头。
|
||||
"""
|
||||
__tablename__ = "etf_basic"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(10), index=True) # 510300
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
exchange: Mapped[str] = mapped_column(String(8)) # SH/SZ
|
||||
list_date: Mapped[str | None] = mapped_column(String(8)) # YYYYMMDD(首根K线日回填)
|
||||
total_mv: Mapped[float | None] = mapped_column(Float) # 总市值(元,东财快照)
|
||||
circ_mv: Mapped[float | None] = mapped_column(Float) # 流通市值(元)
|
||||
turnover_rate: Mapped[float | None] = mapped_column(Float) # 换手率 %
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class StockCompany(Base):
|
||||
"""上市公司基本信息(tushare stock_company,详情页按需单查懒加载 + 30 天新鲜度)。
|
||||
|
||||
行存在但 com_name 为 NULL = 负缓存墓碑(tushare 无此股,避免无数据代码反复穿透)。
|
||||
"""
|
||||
__tablename__ = "stock_company"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), unique=True, index=True) # 000001.SZ
|
||||
com_name: Mapped[str | None] = mapped_column(String(255)) # 公司全称
|
||||
com_id: Mapped[str | None] = mapped_column(String(32)) # 统一社会信用代码
|
||||
chairman: Mapped[str | None] = mapped_column(String(128)) # 法人代表
|
||||
manager: Mapped[str | None] = mapped_column(String(128)) # 总经理
|
||||
secretary: Mapped[str | None] = mapped_column(String(128)) # 董秘
|
||||
reg_capital: Mapped[float | None] = mapped_column(Float) # 注册资本(万元)
|
||||
setup_date: Mapped[str | None] = mapped_column(String(8)) # 注册日期 YYYYMMDD
|
||||
province: Mapped[str | None] = mapped_column(String(32))
|
||||
city: Mapped[str | None] = mapped_column(String(32))
|
||||
introduction: Mapped[str | None] = mapped_column(Text) # 公司介绍
|
||||
website: Mapped[str | None] = mapped_column(String(255))
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
office: Mapped[str | None] = mapped_column(String(255)) # 办公地址
|
||||
employees: Mapped[int | None] = mapped_column(Integer) # 员工人数
|
||||
main_business: Mapped[str | None] = mapped_column(Text) # 主要业务及产品
|
||||
business_scope: Mapped[str | None] = mapped_column(Text) # 经营范围
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class StockFinancial(Base):
|
||||
"""个股财务数据宽表(fina_indicator 指标 + 三大报表关键绝对值,按报告期一行)。
|
||||
|
||||
详情页懒加载,四源合并 upsert:金额单位元(Tushare 原始),
|
||||
比率/同比为百分数。fina_indicator 列无 report_type 概念;
|
||||
三大报表列只取 report_type=1(合并报表)。
|
||||
"""
|
||||
__tablename__ = "stock_financial"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
|
||||
end_date: Mapped[str] = mapped_column(String(8)) # 报告期 YYYYMMDD
|
||||
ann_date: Mapped[str | None] = mapped_column(String(8)) # 公告日 YYYYMMDD
|
||||
# ---- fina_indicator(doc 79)----
|
||||
eps: Mapped[float | None] = mapped_column(Float) # 基本每股收益(元)
|
||||
bps: Mapped[float | None] = mapped_column(Float) # 每股净资产(元)
|
||||
ocfps: Mapped[float | None] = mapped_column(Float) # 每股经营现金流净额(元)
|
||||
roe: Mapped[float | None] = mapped_column(Float) # 净资产收益率 %
|
||||
roe_dt: Mapped[float | None] = mapped_column(Float) # 扣非净资产收益率 %
|
||||
grossprofit_margin: Mapped[float | None] = mapped_column(Float) # 销售毛利率 %
|
||||
netprofit_margin: Mapped[float | None] = mapped_column(Float) # 销售净利率 %
|
||||
debt_to_assets: Mapped[float | None] = mapped_column(Float) # 资产负债率 %
|
||||
or_yoy: Mapped[float | None] = mapped_column(Float) # 营业收入同比 %
|
||||
netprofit_yoy: Mapped[float | None] = mapped_column(Float) # 归母净利润同比 %
|
||||
dt_netprofit_yoy: Mapped[float | None] = mapped_column(Float) # 扣非净利润同比 %
|
||||
profit_dedt: Mapped[float | None] = mapped_column(Float) # 扣非净利润(元)
|
||||
rd_exp: Mapped[float | None] = mapped_column(Float) # 研发投入(元)
|
||||
# ---- income(doc 33,report_type=1)----
|
||||
total_revenue: Mapped[float | None] = mapped_column(Float) # 营业总收入(元)
|
||||
operate_profit: Mapped[float | None] = mapped_column(Float) # 营业利润(元)
|
||||
n_income_attr_p: Mapped[float | None] = mapped_column(Float) # 归母净利润(元)
|
||||
# ---- balancesheet(report_type=1)----
|
||||
total_assets: Mapped[float | None] = mapped_column(Float) # 总资产(元)
|
||||
total_hldr_eqy: Mapped[float | None] = mapped_column(Float) # 归母股东权益(元)
|
||||
# ---- cashflow(report_type=1)----
|
||||
n_cashflow_act: Mapped[float | None] = mapped_column(Float) # 经营现金流净额(元)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ts_code", "end_date", name="uq_stock_financial_code_end"),
|
||||
)
|
||||
|
||||
|
||||
class StockDividend(Base):
|
||||
"""个股分红送股(tushare dividend 全历史;每 code 同步时全量替换,无唯一业务键)。
|
||||
|
||||
预案行(div_proc != 实施)无 ex_date,K 线标记与统计只用实施行。
|
||||
"""
|
||||
__tablename__ = "stock_dividend"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
|
||||
end_date: Mapped[str | None] = mapped_column(String(8)) # 分红年度 YYYYMMDD
|
||||
ann_date: Mapped[str | None] = mapped_column(String(8)) # 预案公告日
|
||||
div_proc: Mapped[str | None] = mapped_column(String(16)) # 实施进度(预案/实施)
|
||||
stk_div: Mapped[float | None] = mapped_column(Float) # 每股送转
|
||||
stk_bo_rate: Mapped[float | None] = mapped_column(Float) # 每股送股比例
|
||||
stk_co_rate: Mapped[float | None] = mapped_column(Float) # 每股转增比例
|
||||
cash_div: Mapped[float | None] = mapped_column(Float) # 每股分红(税后,元)
|
||||
cash_div_tax: Mapped[float | None] = mapped_column(Float) # 每股分红(税前,元)
|
||||
base_share: Mapped[float | None] = mapped_column(Float) # 基准股本(万股)
|
||||
record_date: Mapped[str | None] = mapped_column(String(8)) # 股权登记日
|
||||
ex_date: Mapped[str | None] = mapped_column(String(8)) # 除权除息日(K线标记锚点)
|
||||
pay_date: Mapped[str | None] = mapped_column(String(8)) # 派息日
|
||||
div_listdate: Mapped[str | None] = mapped_column(String(8)) # 红股上市日
|
||||
imp_ann_date: Mapped[str | None] = mapped_column(String(8)) # 实施公告日
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class StockSyncState(Base):
|
||||
"""多行懒加载管道的同步状态(新鲜度门控 + 无数据墓碑)。
|
||||
|
||||
多行表无法像 stock_company 那样用「行存在但业务列 NULL」当墓碑,
|
||||
用本表记录每个 (ts_code, kind) 的同步时间与是否有数据。
|
||||
kind: finance | dividend。
|
||||
"""
|
||||
__tablename__ = "stock_sync_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(16))
|
||||
last_synced_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
|
||||
has_data: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ts_code", "kind", name="uq_stock_sync_code_kind"),
|
||||
)
|
||||
|
||||
|
||||
class StockReference(Base):
|
||||
"""个股参考数据(tushare 参考数据版块按需懒加载,一行 = 一股一分类的 JSON 快照)。
|
||||
|
||||
rows_json 存清洗后的 tushare 原始行数组(单位沿用各接口原始口径,见 reference.py 注册表);
|
||||
行存在但 rows_json 为 NULL = 负缓存墓碑(该股该分类确认无数据)。
|
||||
kind 白名单见 app/data/reference.py 的 REFERENCE_KINDS。
|
||||
"""
|
||||
__tablename__ = "stock_reference"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
|
||||
kind: Mapped[str] = mapped_column(String(24)) # top10_holders | ...
|
||||
rows_json: Mapped[str | None] = mapped_column(Text) # JSON 数组;NULL=墓碑
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ts_code", "kind", name="uq_stock_reference_code_kind"),
|
||||
)
|
||||
|
||||
|
||||
class MarketDaily(Base):
|
||||
"""全市场未复权日线(选股专用,与回测 candles(qfq) 隔离)。
|
||||
|
||||
|
||||
@@ -269,6 +269,89 @@ class PreviewResponse(BaseModel):
|
||||
has_more: bool = False # 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
|
||||
|
||||
|
||||
# ---------- 公司简介(tushare stock_company,详情页按需懒加载) ----------
|
||||
class StockCompanyOut(BaseModel):
|
||||
ts_code: str
|
||||
com_name: str | None = None # 公司全称
|
||||
com_id: str | None = None # 统一社会信用代码
|
||||
chairman: str | None = None # 法人代表
|
||||
manager: str | None = None # 总经理
|
||||
secretary: str | None = None # 董秘
|
||||
reg_capital: float | None = None # 注册资本(万元)
|
||||
setup_date: str | None = None # 注册日期 YYYYMMDD(展示层换算)
|
||||
province: str | None = None # 所在省份
|
||||
city: str | None = None # 所在城市
|
||||
introduction: str | None = None # 公司介绍
|
||||
website: str | None = None # 公司主页
|
||||
email: str | None = None # 电子邮件
|
||||
office: str | None = None # 办公地址
|
||||
employees: int | None = None # 员工人数
|
||||
main_business: str | None = None # 主要业务及产品
|
||||
business_scope: str | None = None # 经营范围
|
||||
|
||||
|
||||
# ---------- 财务数据(fina_indicator + 三大报表关键值,详情页按需懒加载) ----------
|
||||
class StockFinanceRecordOut(BaseModel):
|
||||
"""一行 = 一个报告期。金额单位元(展示层换算亿/万),比率与同比为百分数。"""
|
||||
end_date: str # 报告期 YYYYMMDD
|
||||
ann_date: str | None = None # 公告日 YYYYMMDD
|
||||
eps: float | None = None # 基本每股收益(元)
|
||||
bps: float | None = None # 每股净资产(元)
|
||||
ocfps: float | None = None # 每股经营现金流净额(元)
|
||||
roe: float | None = None # 净资产收益率 %
|
||||
roe_dt: float | None = None # 扣非净资产收益率 %
|
||||
grossprofit_margin: float | None = None # 销售毛利率 %
|
||||
netprofit_margin: float | None = None # 销售净利率 %
|
||||
debt_to_assets: float | None = None # 资产负债率 %
|
||||
or_yoy: float | None = None # 营业收入同比 %
|
||||
netprofit_yoy: float | None = None # 归母净利润同比 %
|
||||
dt_netprofit_yoy: float | None = None # 扣非净利润同比 %
|
||||
profit_dedt: float | None = None # 扣非净利润(元)
|
||||
rd_exp: float | None = None # 研发投入(元)
|
||||
total_revenue: float | None = None # 营业总收入(元)
|
||||
operate_profit: float | None = None # 营业利润(元)
|
||||
n_income_attr_p: float | None = None # 归母净利润(元)
|
||||
total_assets: float | None = None # 总资产(元)
|
||||
total_hldr_eqy: float | None = None # 归母股东权益(元)
|
||||
n_cashflow_act: float | None = None # 经营现金流净额(元)
|
||||
|
||||
|
||||
class StockFinanceOut(BaseModel):
|
||||
ts_code: str
|
||||
records: list[StockFinanceRecordOut] # 按报告期倒序(records[0] = 最新报告期)
|
||||
|
||||
|
||||
# ---------- 分红送股(tushare dividend,详情页按需懒加载) ----------
|
||||
class StockDividendRecordOut(BaseModel):
|
||||
end_date: str | None = None # 分红年度 YYYYMMDD
|
||||
ann_date: str | None = None # 预案公告日
|
||||
div_proc: str | None = None # 实施进度(预案/实施)
|
||||
stk_div: float | None = None # 每股送转
|
||||
stk_bo_rate: float | None = None # 每股送股比例
|
||||
stk_co_rate: float | None = None # 每股转增比例
|
||||
cash_div: float | None = None # 每股分红(税后,元)
|
||||
cash_div_tax: float | None = None # 每股分红(税前,元)
|
||||
base_share: float | None = None # 基准股本(万股)
|
||||
record_date: str | None = None # 股权登记日
|
||||
ex_date: str | None = None # 除权除息日(K线标记锚点)
|
||||
pay_date: str | None = None # 派息日
|
||||
div_listdate: str | None = None # 红股上市日
|
||||
imp_ann_date: str | None = None # 实施公告日
|
||||
|
||||
|
||||
class StockDividendOut(BaseModel):
|
||||
ts_code: str
|
||||
records: list[StockDividendRecordOut] # 按分红年度倒序;空列表 = 确认无分红
|
||||
|
||||
|
||||
# ---------- 参考数据(tushare 参考数据版块,详情页按需懒加载) ----------
|
||||
class StockReferenceOut(BaseModel):
|
||||
"""rows_json 快照直出:records 为清洗后的 tushare 原始行,字段随 kind 而异。"""
|
||||
ts_code: str
|
||||
kind: str # 白名单见 app/data/reference.py REFERENCE_KINDS
|
||||
records: list[dict[str, str | float | None]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
@@ -322,6 +405,45 @@ class StockFacetsResponse(BaseModel):
|
||||
areas: list[FacetItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- ETF 列表(全市场浏览;行情走 candles 底座,规模走东财快照) ----------
|
||||
class EtfListItemOut(BaseModel):
|
||||
ts_code: str # 510300.SH
|
||||
symbol: str # 510300
|
||||
name: str
|
||||
exchange: str # SH/SZ
|
||||
close: float | None = None # 最新收盘(candles 未复权)
|
||||
prev_close: float | None = None
|
||||
pct_chg: float | None = None # 最新两根日线计算
|
||||
amount: float | None = None # 最新成交额(亿元,candles 最新 bar)
|
||||
last_ts: datetime | None = None
|
||||
turnover_rate: float | None = None # 换手率 %(东财快照)
|
||||
total_mv: float | None = None # 总市值(亿元,东财快照)
|
||||
circ_mv: float | None = None # 流通市值(亿元)
|
||||
list_date: str | None = None # 上市日 YYYYMMDD(首根K线日)
|
||||
watched: bool = False # 是否自选(当前用户)
|
||||
|
||||
|
||||
class EtfListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[EtfListItemOut]
|
||||
|
||||
|
||||
class EtfSyncRequest(BaseModel):
|
||||
full: bool = False # true = 忽略增量起点全量重拉(修数据用)
|
||||
|
||||
|
||||
class EtfSyncStatus(BaseModel):
|
||||
running: bool
|
||||
step: str | None = None
|
||||
total: int = 0 # 本次需拉 K 线的 ETF 数
|
||||
done: int = 0
|
||||
error: str | None = None
|
||||
ready: bool = False # etf_basic 非空且已有日线
|
||||
last_trade_date: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
stats: dict[str, int] = Field(default_factory=dict) # etfs
|
||||
|
||||
|
||||
# ---------- 用户偏好 / 自选股 / 提问历史 ----------
|
||||
class PreferencesOut(BaseModel):
|
||||
prefs: dict[str, object] = Field(default_factory=dict) # key -> JSON 值
|
||||
@@ -413,3 +535,86 @@ class MarketOverviewResponse(BaseModel):
|
||||
amount_history: list[AmountBarOut] = [] # 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
|
||||
errors: list[str] = [] # 部分来源失败的说明(透明但不阻塞展示)
|
||||
|
||||
|
||||
# ---------- 指数专题(国际指数卡片 + 指数详情) ----------
|
||||
|
||||
class GlobalIndexQuoteOut(BaseModel):
|
||||
"""国际指数卡片(index_global 最新收盘 + 45 日 spark)。"""
|
||||
code: str # DJI / SPX / HSI ...
|
||||
name: str # 道琼斯工业指数
|
||||
region: str # americas | europe | asia(前端分组)
|
||||
country: str # 美国 / 英国 / 日本 ...
|
||||
close: float | None = None
|
||||
change: float | None = None
|
||||
pct_chg: float | None = None
|
||||
trade_date: date | None = None
|
||||
spark: list[float] = []
|
||||
spark_dates: list[str] = []
|
||||
|
||||
|
||||
class GlobalIndexListResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
items: list[GlobalIndexQuoteOut] = []
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class IndexBasicOut(BaseModel):
|
||||
"""指数基本信息:国内 index_basic(市场/发布方/基期基点),国际由静态表合成(国家/地区)。"""
|
||||
ts_code: str
|
||||
name: str
|
||||
market: str | None = None # SSE / CSI / SZSE ...
|
||||
publisher: str | None = None # 中证指数 / 上交所
|
||||
category: str | None = None # 规模指数 / 综合指数
|
||||
base_date: date | None = None # 基期
|
||||
base_point: float | None = None # 基点
|
||||
list_date: date | None = None # 发布日期
|
||||
country: str | None = None # 国际指数:国家/地区
|
||||
region: str | None = None # 国际指数:americas/europe/asia
|
||||
|
||||
|
||||
class IndexValuationPointOut(BaseModel):
|
||||
"""大盘指数每日指标的一日(index_dailybasic,仅部分国内指数有数据)。"""
|
||||
trade_date: date
|
||||
pe: float | None = None
|
||||
pe_ttm: float | None = None
|
||||
pb: float | None = None
|
||||
turnover_rate: float | None = None # 换手率 %
|
||||
total_mv: float | None = None # 总市值(元;tushare 文档标注万元,实测按元才对得上量级)
|
||||
float_mv: float | None = None # 流通市值(元)
|
||||
|
||||
|
||||
class IndexQuoteBriefOut(BaseModel):
|
||||
"""详情页头部最新行情(收盘口径)。"""
|
||||
close: float | None = None
|
||||
change: float | None = None
|
||||
pct_chg: float | None = None
|
||||
open: float | None = None
|
||||
high: float | None = None
|
||||
low: float | None = None
|
||||
pre_close: float | None = None
|
||||
trade_date: date | None = None
|
||||
spark: list[float] = []
|
||||
spark_dates: list[str] = []
|
||||
|
||||
|
||||
class IndexDetailResponse(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
region: str # cn | americas | europe | asia
|
||||
quote: IndexQuoteBriefOut
|
||||
basic: IndexBasicOut | None = None
|
||||
valuation: IndexValuationPointOut | None = None # 最新一日(接口不覆盖时 None)
|
||||
valuation_history: list[IndexValuationPointOut] = [] # 近 N 日(PE 走势小图)
|
||||
|
||||
|
||||
class IndexWeightItemOut(BaseModel):
|
||||
con_code: str # 成分股 000001.SZ
|
||||
name: str | None = None # 平安银行(本地 stock_basic 回填,缺则 None)
|
||||
weight: float # 权重 %
|
||||
|
||||
|
||||
class IndexWeightsResponse(BaseModel):
|
||||
trade_date: date
|
||||
total: int # 成分股总数(返回 items 为按权重降序的子集)
|
||||
items: list[IndexWeightItemOut] = []
|
||||
|
||||
|
||||
@@ -218,9 +218,16 @@ async def _replace_day(session: AsyncSession, model, rows: list[dict], d_str: st
|
||||
|
||||
|
||||
async def _existing_candle_dates(session: AsyncSession) -> set[str]:
|
||||
"""candles 表已落库的交易日集合(YYYYMMDD 字符串,便于比对)。"""
|
||||
"""candles 表已落库的交易日集合(YYYYMMDD 字符串,便于比对)。
|
||||
|
||||
限定在市股票符号:candles 底座同时容纳 ETF(etf_sync 写入),若不隔离,
|
||||
只同步了 ETF 的交易日会被误判为「股票日线已完成」而跳过当日股票同步。
|
||||
"""
|
||||
res = await session.execute(
|
||||
select(func.distinct(func.date(Candle.ts))).where(Candle.timeframe == "1d")
|
||||
select(func.distinct(func.date(Candle.ts))).where(
|
||||
Candle.timeframe == "1d",
|
||||
Candle.symbol.in_(select(StockBasic.symbol).where(StockBasic.list_status == "L")),
|
||||
)
|
||||
)
|
||||
return {r[0].strftime("%Y%m%d") for r in res if r[0] is not None}
|
||||
|
||||
@@ -339,12 +346,12 @@ async def _run_sync(days: int, force: bool) -> None:
|
||||
|
||||
# candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效)
|
||||
await cache.bump_version("candles")
|
||||
# 预热同步状态缓存:同步任务自己付一次重聚合(>10s),轮询方毫秒级拿到新数字
|
||||
# 预热统计缓存:同步任务自己付一次重聚合(>10s)。SWR 下轮询方不等待——
|
||||
# 先拿到旧值(last_trade_date 本就实时),重算完成后数字自然换新
|
||||
_sync_state["step"] = "正在更新统计缓存"
|
||||
try:
|
||||
async with async_session() as s2:
|
||||
await _db_stats(s2)
|
||||
except Exception: # noqa: BLE001 —— 预热失败只影响下一次轮询的时延
|
||||
await _refresh_stats(await cache.get_version("candles"))
|
||||
except Exception: # noqa: BLE001 —— 预热失败只影响统计数字的新鲜度
|
||||
pass
|
||||
_sync_state["step"] = "同步完成"
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -369,12 +376,26 @@ async def start_sync(session: AsyncSession, days: int, force: bool) -> dict:
|
||||
return dict(_sync_state)
|
||||
|
||||
|
||||
# candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s;
|
||||
# 结果按 ver:candles 版本号缓存(同步完成即 bump 失效),前端 2s 轮询只付毫秒级。
|
||||
# candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s。
|
||||
# 读路径 SWR:版本失效(同步完成 bump)/进程重启后,先吐最近一次旧值(进程内 →
|
||||
# Redis 无版本 last 键),后台单飞重算——轮询请求**永不等待**重聚合(旧设计里
|
||||
# 轮询会在 _stats_lock 上排队 >10s,表现为首页「数据更新至」加载不出来);
|
||||
# 只有史上第一次(进程内与 Redis 都无记录)才现场算。
|
||||
# last_daily(max(ts),走索引很快)保持每次实时——它是 UI 主展示字段。
|
||||
_status_stats_cache: dict = {"at": 0.0, "ver": -1, "data": None}
|
||||
_STATS_TTL = 120.0 # 进程内兜底 TTL(Redis 不可用时重聚合的最小间隔)
|
||||
_stats_bg_tasks: set[asyncio.Task] = set() # 后台写缓存的引用,防 GC
|
||||
_stats_lock = asyncio.Lock() # 单飞锁:同步尾部的预热与轮询并发时,重聚合只跑一次
|
||||
_stats_bg_tasks: set[asyncio.Task] = set() # 后台任务引用,防 GC
|
||||
_stats_lock = asyncio.Lock() # 单飞锁:同一时刻至多一个重聚合在跑
|
||||
|
||||
_STATS_LAST_KEY = "syncstats:last" # 无版本号的最近一次结果(跨版本/跨重启兜底)
|
||||
|
||||
|
||||
def _fresh_local(ver: int) -> dict | None:
|
||||
d = _status_stats_cache["data"]
|
||||
if d is not None and _status_stats_cache["ver"] == ver \
|
||||
and time.time() - _status_stats_cache["at"] < _STATS_TTL:
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def _heavy_stats(session: AsyncSession) -> dict:
|
||||
@@ -391,30 +412,52 @@ async def _heavy_stats(session: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _store_stats(ver: int, data: dict) -> None:
|
||||
_status_stats_cache.update(at=time.time(), ver=ver, data=data)
|
||||
# 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢调用方
|
||||
tasks = [
|
||||
asyncio.create_task(cache.cache_set(
|
||||
f"syncstats:v{ver}", data, ttl=settings.sync_stats_redis_ttl)),
|
||||
asyncio.create_task(cache.cache_set(
|
||||
_STATS_LAST_KEY, data, ttl=settings.sync_stats_redis_ttl)),
|
||||
]
|
||||
_stats_bg_tasks.update(tasks)
|
||||
for t in tasks:
|
||||
t.add_done_callback(_stats_bg_tasks.discard)
|
||||
|
||||
|
||||
async def _refresh_stats(ver: int) -> None:
|
||||
"""后台重算(单飞):等锁双检后重聚合并写缓存。已有重算在跑则直接返回。"""
|
||||
from ..db import async_session # 延迟导入避免循环
|
||||
|
||||
if _stats_lock.locked():
|
||||
return
|
||||
async with _stats_lock:
|
||||
if _fresh_local(ver) is not None:
|
||||
return # 等锁期间已被并发填充
|
||||
async with async_session() as s:
|
||||
data = await _heavy_stats(s)
|
||||
await _store_stats(ver, data)
|
||||
|
||||
|
||||
async def _db_stats(session: AsyncSession) -> dict:
|
||||
"""candles/快照/股票列表实况:重聚合走「版本化 Redis + 进程内」双层缓存,
|
||||
last_daily(max(ts),走索引很快)保持每次实时——它是 UI 主展示字段。"""
|
||||
|
||||
def _fresh_local(ver: int) -> dict | None:
|
||||
d = _status_stats_cache["data"]
|
||||
if d is not None and _status_stats_cache["ver"] == ver \
|
||||
and time.time() - _status_stats_cache["at"] < _STATS_TTL:
|
||||
return d
|
||||
return None
|
||||
|
||||
ver = await cache.get_version("candles")
|
||||
heavy = _fresh_local(ver)
|
||||
if heavy is None:
|
||||
async with _stats_lock: # 双检:等锁期间可能已被并发请求/同步预热填充
|
||||
heavy = _fresh_local(ver) or await cache.cache_get(f"syncstats:v{ver}")
|
||||
if heavy is None:
|
||||
heavy = await _heavy_stats(session)
|
||||
# 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢本次返回
|
||||
task = asyncio.create_task(
|
||||
cache.cache_set(f"syncstats:v{ver}", heavy, ttl=settings.sync_stats_redis_ttl))
|
||||
_stats_bg_tasks.add(task)
|
||||
task.add_done_callback(_stats_bg_tasks.discard)
|
||||
_status_stats_cache.update(at=time.time(), ver=ver, data=heavy)
|
||||
stale = _status_stats_cache["data"] or await cache.cache_get(_STATS_LAST_KEY)
|
||||
if stale is not None:
|
||||
# SWR:先返旧值(行数等数字仅展示用,旧几秒无害),后台重算
|
||||
heavy = stale
|
||||
t = asyncio.create_task(_refresh_stats(ver))
|
||||
_stats_bg_tasks.add(t)
|
||||
t.add_done_callback(_stats_bg_tasks.discard)
|
||||
else:
|
||||
# 史上第一次(进程内与 Redis 均无记录):只能现场算,锁内单飞
|
||||
async with _stats_lock:
|
||||
heavy = _fresh_local(ver) or await cache.cache_get(f"syncstats:v{ver}")
|
||||
if heavy is None:
|
||||
heavy = await _heavy_stats(session)
|
||||
await _store_stats(ver, heavy)
|
||||
last_daily = await session.scalar(
|
||||
select(func.max(Candle.ts)).where(Candle.timeframe == "1d")
|
||||
)
|
||||
|
||||
@@ -139,12 +139,11 @@ def _to_date(v) -> date | None:
|
||||
|
||||
|
||||
def _to_code_suffix(code: str) -> str:
|
||||
"""6 位代码 → 交易所后缀(60/68 沪,00/30 深,4/8/92 北交所)。"""
|
||||
if code.startswith(("60", "68", "90")):
|
||||
return ".SH"
|
||||
if code.startswith(("00", "30", "20")):
|
||||
return ".SZ"
|
||||
return ".BJ"
|
||||
"""6 位代码 → 交易所后缀。委托 to_ts_code(含场内 ETF:51/56/58 沪、159 深)。"""
|
||||
from .data.symbols import to_ts_code
|
||||
|
||||
ts_code = to_ts_code(code)
|
||||
return ts_code[ts_code.index("."):]
|
||||
|
||||
|
||||
def _direction(op: str) -> str | None:
|
||||
|
||||
1035
backend_run.log
1035
backend_run.log
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ async function signOut() {
|
||||
</main>
|
||||
|
||||
<footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-[13px] text-[#9BA3AE]">
|
||||
数据来源 Tushare · 仅供研究学习,不构成投资建议
|
||||
数据来源 Tushare / 东方财富 · 仅供研究学习,不构成投资建议
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
CurrentUser,
|
||||
EventBacktestRequest,
|
||||
EventBacktestResponse,
|
||||
EtfListResponse,
|
||||
EtfSyncStatus,
|
||||
GlobalIndexList,
|
||||
IndexDetail,
|
||||
IndexWeights,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MarketOverview,
|
||||
@@ -16,7 +21,11 @@ import type {
|
||||
ScreenerRunResponse,
|
||||
ScreenerSyncRequest,
|
||||
ScreenerSyncStatus,
|
||||
StockCompanyInfo,
|
||||
StockDividendOut,
|
||||
StockFacets,
|
||||
StockFinanceOut,
|
||||
StockReferenceOut,
|
||||
StockListResponse,
|
||||
SyncRequest,
|
||||
SyncResponse,
|
||||
@@ -191,6 +200,34 @@ export async function getStockPreview(
|
||||
return (await res.json()) as PreviewResponse;
|
||||
}
|
||||
|
||||
/** 个股公司简介(tushare stock_company 按需懒加载;404 = ETF/无此股,调用方据此隐藏面板) */
|
||||
export async function getStockCompany(tsCode: string): Promise<StockCompanyInfo> {
|
||||
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/company`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取公司简介失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as StockCompanyInfo;
|
||||
}
|
||||
|
||||
/** 个股财务数据(fina_indicator+三大报表关键值,近五年;404 = ETF/无数据,调用方据此隐藏面板) */
|
||||
export async function getStockFinance(tsCode: string): Promise<StockFinanceOut> {
|
||||
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/finance`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取财务数据失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as StockFinanceOut;
|
||||
}
|
||||
|
||||
/** 个股分红送股(tushare dividend 全历史;空 records = 确认无分红) */
|
||||
export async function getStockDividends(tsCode: string): Promise<StockDividendOut> {
|
||||
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/dividends`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取分红数据失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as StockDividendOut;
|
||||
}
|
||||
|
||||
/** 个股参考数据(tushare 参考数据版块按 kind 懒加载;空 records = 确认无数据) */
|
||||
export async function getStockReference(tsCode: string, kind: string): Promise<StockReferenceOut> {
|
||||
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/reference/${encodeURIComponent(kind)}`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取参考数据失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as StockReferenceOut;
|
||||
}
|
||||
|
||||
// ---------- 大盘总览(主页) ----------
|
||||
export async function getMarketOverview(): Promise<MarketOverview> {
|
||||
const res = await apiFetch('/api/market/overview');
|
||||
@@ -205,6 +242,37 @@ export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
|
||||
return (await res.json()) as Candle[];
|
||||
}
|
||||
|
||||
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
|
||||
export async function getGlobalIndexes(): Promise<GlobalIndexList> {
|
||||
const res = await apiFetch('/api/market/global-indexes');
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取国际指数失败'), res.status);
|
||||
return (await res.json()) as GlobalIndexList;
|
||||
}
|
||||
|
||||
export async function getIndexDetail(code: string): Promise<IndexDetail> {
|
||||
const res = await apiFetch(`/api/market/indexes/${encodeURIComponent(code)}`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取指数详情失败'), res.status);
|
||||
return (await res.json()) as IndexDetail;
|
||||
}
|
||||
|
||||
/** 白名单指数全量 K 线(国内 index_daily / 国际 index_global,日线基底聚合) */
|
||||
export async function getAnyIndexCandles(code: string, timeframe: Timeframe): Promise<Candle[]> {
|
||||
const res = await apiFetch(
|
||||
`/api/market/indexes/${encodeURIComponent(code)}/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[];
|
||||
}
|
||||
|
||||
/** 指数成分股权重(仅国内指数;国际指数后端 404) */
|
||||
export async function getIndexWeights(code: string, limit = 50): Promise<IndexWeights> {
|
||||
const res = await apiFetch(
|
||||
`/api/market/indexes/${encodeURIComponent(code)}/weights?limit=${limit}`,
|
||||
);
|
||||
if (!res.ok) throw new ApiError(await readError(res, '获取成分权重失败'), res.status);
|
||||
return (await res.json()) as IndexWeights;
|
||||
}
|
||||
|
||||
export async function getStocks(params: {
|
||||
search?: string;
|
||||
market?: string;
|
||||
@@ -237,6 +305,41 @@ export async function getStockFacets(): Promise<StockFacets> {
|
||||
return (await res.json()) as StockFacets;
|
||||
}
|
||||
|
||||
// ---------- ETF 列表(全市场浏览) ----------
|
||||
export async function getEtfs(params: {
|
||||
search?: string;
|
||||
exchange?: string;
|
||||
watched_only?: boolean;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<EtfListResponse> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.search) q.set('search', params.search);
|
||||
if (params.exchange) q.set('exchange', params.exchange);
|
||||
if (params.watched_only) q.set('watched_only', 'true');
|
||||
if (params.sort) q.set('sort', params.sort);
|
||||
if (params.order) q.set('order', params.order);
|
||||
q.set('limit', String(params.limit ?? 100));
|
||||
q.set('offset', String(params.offset ?? 0));
|
||||
const res = await apiFetch(`/api/etfs?${q.toString()}`);
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as EtfListResponse;
|
||||
}
|
||||
|
||||
export async function startEtfSync(full = false): Promise<EtfSyncStatus> {
|
||||
const res = await apiFetch('/api/etf/sync', { method: 'POST', body: JSON.stringify({ full }) });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `启动 ETF 同步失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as EtfSyncStatus;
|
||||
}
|
||||
|
||||
export async function getEtfSyncStatus(): Promise<EtfSyncStatus> {
|
||||
const res = await apiFetch('/api/etf/sync/status');
|
||||
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 同步状态失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as EtfSyncStatus;
|
||||
}
|
||||
|
||||
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
|
||||
export async function getPreferences(): Promise<Record<string, unknown>> {
|
||||
const res = await apiFetch('/api/preferences');
|
||||
|
||||
@@ -209,6 +209,105 @@ export interface PreviewResponse {
|
||||
has_more?: boolean; // 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
|
||||
}
|
||||
|
||||
// ---------- 公司简介(镜像 app/schemas.py StockCompanyOut) ----------
|
||||
export interface StockCompanyInfo {
|
||||
ts_code: string;
|
||||
com_name?: string | null; // 公司全称
|
||||
com_id?: string | null; // 统一社会信用代码
|
||||
chairman?: string | null; // 法人代表
|
||||
manager?: string | null; // 总经理
|
||||
secretary?: string | null; // 董秘
|
||||
reg_capital?: number | null; // 万元
|
||||
setup_date?: string | null; // YYYYMMDD
|
||||
province?: string | null;
|
||||
city?: string | null;
|
||||
introduction?: string | null; // 公司介绍
|
||||
website?: string | null;
|
||||
email?: string | null;
|
||||
office?: string | null;
|
||||
employees?: number | null;
|
||||
main_business?: string | null; // 主要业务及产品
|
||||
business_scope?: string | null; // 经营范围
|
||||
}
|
||||
|
||||
// ---------- 财务数据(镜像 app/schemas.py StockFinanceOut,近五年按报告期倒序) ----------
|
||||
export interface StockFinanceRecord {
|
||||
end_date: string; // 报告期 YYYYMMDD
|
||||
ann_date?: string | null; // 公告日 YYYYMMDD
|
||||
eps?: number | null; // 基本每股收益(元)
|
||||
bps?: number | null; // 每股净资产(元)
|
||||
ocfps?: number | null; // 每股经营现金流净额(元)
|
||||
roe?: number | null; // 净资产收益率 %
|
||||
roe_dt?: number | null; // 扣非净资产收益率 %
|
||||
grossprofit_margin?: number | null; // 销售毛利率 %
|
||||
netprofit_margin?: number | null; // 销售净利率 %
|
||||
debt_to_assets?: number | null; // 资产负债率 %
|
||||
or_yoy?: number | null; // 营业收入同比 %
|
||||
netprofit_yoy?: number | null; // 归母净利润同比 %
|
||||
dt_netprofit_yoy?: number | null; // 扣非净利润同比 %
|
||||
profit_dedt?: number | null; // 扣非净利润(元)
|
||||
rd_exp?: number | null; // 研发投入(元)
|
||||
total_revenue?: number | null; // 营业总收入(元)
|
||||
operate_profit?: number | null; // 营业利润(元)
|
||||
n_income_attr_p?: number | null; // 归母净利润(元)
|
||||
total_assets?: number | null; // 总资产(元)
|
||||
total_hldr_eqy?: number | null; // 归母股东权益(元)
|
||||
n_cashflow_act?: number | null; // 经营现金流净额(元)
|
||||
}
|
||||
|
||||
export interface StockFinanceOut {
|
||||
ts_code: string;
|
||||
records: StockFinanceRecord[]; // records[0] = 最新报告期
|
||||
}
|
||||
|
||||
// ---------- 分红送股(镜像 app/schemas.py StockDividendOut,全历史按分红年度倒序) ----------
|
||||
export interface StockDividendRecord {
|
||||
end_date?: string | null; // 分红年度 YYYYMMDD
|
||||
ann_date?: string | null; // 预案公告日
|
||||
div_proc?: string | null; // 实施进度(预案/实施)
|
||||
stk_div?: number | null; // 每股送转
|
||||
stk_bo_rate?: number | null; // 每股送股比例
|
||||
stk_co_rate?: number | null; // 每股转增比例
|
||||
cash_div?: number | null; // 每股分红(税后,元)
|
||||
cash_div_tax?: number | null; // 每股分红(税前,元)
|
||||
base_share?: number | null; // 基准股本(万股)
|
||||
record_date?: string | null; // 股权登记日
|
||||
ex_date?: string | null; // 除权除息日(K线标记锚点)
|
||||
pay_date?: string | null; // 派息日
|
||||
div_listdate?: string | null; // 红股上市日
|
||||
imp_ann_date?: string | null; // 实施公告日
|
||||
}
|
||||
|
||||
export interface StockDividendOut {
|
||||
ts_code: string;
|
||||
records: StockDividendRecord[]; // 空数组 = 确认无分红
|
||||
}
|
||||
|
||||
// ---------- 参考数据(镜像 app/schemas.py StockReferenceOut,字段随 kind 而异) ----------
|
||||
export type StockReferenceRecord = Record<string, string | number | null>;
|
||||
|
||||
export interface StockReferenceOut {
|
||||
ts_code: string;
|
||||
kind: string; // 白名单 key,见 REFERENCE_KINDS
|
||||
records: StockReferenceRecord[]; // 空数组 = 确认无数据
|
||||
}
|
||||
|
||||
/** 参考数据分类目录(与后端 reference.REFERENCE_KINDS 白名单一一对应) */
|
||||
export const REFERENCE_KINDS: { key: string; label: string }[] = [
|
||||
{ key: 'top10_holders', label: '十大股东' },
|
||||
{ key: 'top10_floatholders', label: '十大流通' },
|
||||
{ key: 'pledge_stat', label: '质押统计' },
|
||||
{ key: 'pledge_detail', label: '质押明细' },
|
||||
{ key: 'repurchase', label: '回购' },
|
||||
{ key: 'share_float', label: '解禁' },
|
||||
{ key: 'block_trade', label: '大宗' },
|
||||
{ key: 'moneyflow', label: '资金流向' },
|
||||
{ key: 'holdernumber', label: '股东人数' },
|
||||
{ key: 'holdertrade', label: '增减持' },
|
||||
{ key: 'shock', label: '异动' },
|
||||
{ key: 'high_shock', label: '严重异动' },
|
||||
];
|
||||
|
||||
// ---------- 股票列表(全市场浏览) ----------
|
||||
export interface StockListItem {
|
||||
ts_code: string;
|
||||
@@ -243,6 +342,41 @@ export interface StockFacets {
|
||||
areas: FacetItem[];
|
||||
}
|
||||
|
||||
// ---------- ETF 列表(全市场浏览) ----------
|
||||
export interface EtfListItem {
|
||||
ts_code: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
exchange: string; // SH/SZ
|
||||
close?: number | null;
|
||||
prev_close?: number | null;
|
||||
pct_chg?: number | null;
|
||||
amount?: number | null; // 最新成交额(亿元)
|
||||
last_ts?: string | null;
|
||||
turnover_rate?: number | null; // 换手率 %(东财快照)
|
||||
total_mv?: number | null; // 总市值(亿元)
|
||||
circ_mv?: number | null; // 流通市值(亿元)
|
||||
list_date?: string | null; // 上市日 YYYYMMDD
|
||||
watched: boolean;
|
||||
}
|
||||
|
||||
export interface EtfListResponse {
|
||||
total: number;
|
||||
items: EtfListItem[];
|
||||
}
|
||||
|
||||
export interface EtfSyncStatus {
|
||||
running: boolean;
|
||||
step?: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
error?: string | null;
|
||||
ready: boolean;
|
||||
last_trade_date?: string | null;
|
||||
last_synced_at?: string | null;
|
||||
stats: { etfs?: number } & Record<string, number>;
|
||||
}
|
||||
|
||||
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
|
||||
export type Timeframe = '1d' | '1w' | '1M' | '1y';
|
||||
export type AdjustMode = 'bfq' | 'qfq' | 'hfq';
|
||||
@@ -263,6 +397,7 @@ export interface ChartLayoutPrefs {
|
||||
indexTimeframe?: Timeframe; // 首页上证指数 K 线周期
|
||||
showBoll?: boolean; // 主图叠加 BOLL
|
||||
showZhixing?: boolean; // 主图叠加知行指标(趋势线+多空线)
|
||||
showDividends?: boolean; // K线分红标记(默认开)
|
||||
}
|
||||
|
||||
export interface ScreenerQueryItem {
|
||||
@@ -396,3 +531,83 @@ export interface MarketOverview {
|
||||
amount_history: AmountBar[]; // 近 N 交易日两市成交额(旧 -> 新,末根可能盘中)
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
|
||||
export type GlobalRegion = 'americas' | 'europe' | 'asia';
|
||||
|
||||
export interface GlobalIndexQuote {
|
||||
code: string; // DJI / SPX / HSI ...
|
||||
name: string; // 道琼斯工业指数
|
||||
region: GlobalRegion;
|
||||
country: string; // 美国 / 英国 / 日本 ...
|
||||
close: number | null;
|
||||
change: number | null;
|
||||
pct_chg: number | null;
|
||||
trade_date: string | null; // ISO 日期
|
||||
spark: number[]; // 近 45 交易日收盘(旧 -> 新)
|
||||
spark_dates: string[];
|
||||
}
|
||||
|
||||
export interface GlobalIndexList {
|
||||
updated_at: string;
|
||||
items: GlobalIndexQuote[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface IndexBasic {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
market?: string | null; // SSE / CSI / SZSE(国内)
|
||||
publisher?: string | null; // 中证指数 / 上交所
|
||||
category?: string | null; // 规模指数 / 综合指数
|
||||
base_date?: string | null; // 基期
|
||||
base_point?: number | null; // 基点
|
||||
list_date?: string | null; // 发布日期
|
||||
country?: string | null; // 国际指数:国家/地区
|
||||
region?: string | null; // 国际指数:americas/europe/asia
|
||||
}
|
||||
|
||||
export interface IndexValuationPoint {
|
||||
trade_date: string;
|
||||
pe?: number | null;
|
||||
pe_ttm?: number | null;
|
||||
pb?: number | null;
|
||||
turnover_rate?: number | null; // 换手率 %
|
||||
total_mv?: number | null; // 总市值(万元)
|
||||
float_mv?: number | null; // 流通市值(万元)
|
||||
}
|
||||
|
||||
export interface IndexQuoteBrief {
|
||||
close: number | null;
|
||||
change: number | null;
|
||||
pct_chg: number | null;
|
||||
open: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
pre_close: number | null;
|
||||
trade_date: string | null;
|
||||
spark: number[];
|
||||
spark_dates: string[];
|
||||
}
|
||||
|
||||
export interface IndexDetail {
|
||||
code: string;
|
||||
name: string;
|
||||
region: 'cn' | GlobalRegion;
|
||||
quote: IndexQuoteBrief;
|
||||
basic: IndexBasic | null;
|
||||
valuation: IndexValuationPoint | null; // 接口不覆盖该指数时为 null
|
||||
valuation_history: IndexValuationPoint[]; // 近 N 日(PE 走势小图)
|
||||
}
|
||||
|
||||
export interface IndexWeightItem {
|
||||
con_code: string; // 000001.SZ
|
||||
name: string | null; // 平安银行(本地回填,缺则 null)
|
||||
weight: number; // 权重 %
|
||||
}
|
||||
|
||||
export interface IndexWeights {
|
||||
trade_date: string;
|
||||
total: number;
|
||||
items: IndexWeightItem[];
|
||||
}
|
||||
|
||||
@@ -29,15 +29,16 @@ for (const t of [
|
||||
registerOverlay(t);
|
||||
}
|
||||
|
||||
// ---------- 实盘买卖点标记(交割单导入) ----------
|
||||
// ---------- 实盘买卖点 / 分红事件标记(同一模板,两组 groupId 独立管理) ----------
|
||||
// v10 无 v9 的 simpleMarker,须注册自定义模板;字母种类/明细经 extendData 传入。
|
||||
// A股惯例(通达信/同花顺同款):B 买贴 low 下方、S 卖贴 high 上方、T 当日买+卖(做T)贴 high 上方;
|
||||
// 图上只显示单个字母徽章(色底白字,用户指定固定配色,不随涨跌设置),成交明细(数量/均价/费用)
|
||||
// 悬停字母时由组件浮层展示——onMouseEnter/onMouseLeave 是创建项级回调(OverlayCreate 未 Omit
|
||||
// 事件键),闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
|
||||
// D 分红除权日贴 high 上方(紫)。图上只显示单个字母徽章(色底白字,用户指定固定配色,
|
||||
// 不随涨跌设置),明细(成交数量/均价/费用 或 分红方案)悬停字母时由组件浮层展示——
|
||||
// onMouseEnter/onMouseLeave 是创建项级回调(OverlayCreate 未 Omit 事件键),
|
||||
// 闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
|
||||
interface TradeRow { label: string; text: string; tone: 'buy' | 'sell' | '' }
|
||||
interface TradeMarkExt { kind: 'B' | 'S' | 'T'; rows: TradeRow[] }
|
||||
const TRADE_COLORS: Record<'B' | 'S' | 'T', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504' };
|
||||
interface TradeMarkExt { kind: 'B' | 'S' | 'T' | 'D'; rows: TradeRow[] }
|
||||
const TRADE_COLORS: Record<'B' | 'S' | 'T' | 'D', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504', D: '#9C6ADE' };
|
||||
const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
|
||||
name: 'tradeMarker',
|
||||
totalStep: 2,
|
||||
@@ -106,6 +107,9 @@ const props = defineProps<{
|
||||
* T=当日买+卖(做T)贴 high 上方;rows 为悬停明细(数量/均价/费用)。
|
||||
* 只画落在已渲染窗口内的(更早的等左滑翻页后自动补画) */
|
||||
tradeMarkers?: { key: string; ts: number; kind: 'B' | 'S' | 'T'; rows: TradeRow[] }[];
|
||||
/** 分红事件标记(tushare dividend,按除权除息日贴 high 上方,kind 固定 'D'):
|
||||
* rows 为悬停明细(每股分红/送转/登记日等)。与买卖点同模板不同 groupId,独立开关 */
|
||||
dividendMarkers?: { key: string; ts: number; rows: TradeRow[] }[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -520,8 +524,8 @@ function pickTool(key: string) {
|
||||
function clearOverlays() {
|
||||
chart?.removeOverlay();
|
||||
activeTool.value = '';
|
||||
// removeOverlay() 无参清的是全部 overlay(含交易点)——交易点不是用户画线,重画回来
|
||||
renderTradeMarkers();
|
||||
// removeOverlay() 无参清的是全部 overlay(含交易点/分红)——事件标记不是用户画线,重画回来
|
||||
renderMarkers();
|
||||
}
|
||||
|
||||
// ---------- 日期跳转居中 ----------
|
||||
@@ -559,12 +563,13 @@ function centerOn(ts: number): boolean {
|
||||
}
|
||||
defineExpose({ centerOn });
|
||||
|
||||
// ---------- 实盘买卖点标记渲染 ----------
|
||||
// ---------- 事件标记渲染(买卖点 + 分红,同模板不同 groupId) ----------
|
||||
const TRADE_GROUP = 'trades';
|
||||
const DIVIDEND_GROUP = 'dividends';
|
||||
|
||||
/** 交易点允许吸附到「晚于最后一根K时间戳」的窗口,按周期放大:周/月/年K的 bar 时间戳
|
||||
* 是周期首日(周一/1日/1月1日),当前周期内的成交(如月中)仍应贴到最后一根上。
|
||||
* 日K严格为 0:行情未同步到成交日时宁可先不画(数据同步后重建图表自动补上),
|
||||
/** 事件允许吸附到「晚于最后一根K时间戳」的窗口,按周期放大:周/月/年K的 bar 时间戳
|
||||
* 是周期首日(周一/1日/1月1日),当前周期内的事件(如月中成交/除权)仍应贴到最后一根上。
|
||||
* 日K严格为 0:行情未同步到事件日时宁可先不画(数据同步后重建图表自动补上),
|
||||
* 也不能把周一的成交错标到周五的K线上。 */
|
||||
const TRADE_AHEAD_MS: Record<string, number> = {
|
||||
'1d': 0,
|
||||
@@ -573,27 +578,27 @@ const TRADE_AHEAD_MS: Record<string, number> = {
|
||||
'1y': 366 * 86400000,
|
||||
};
|
||||
|
||||
/** 按 groupId 整组重建买卖点标记(先删后建,幂等)。交易日期按时间戳吸附到所在 bar:
|
||||
* B 贴 bar.low 下方、S/T 贴 bar.high 上方;坐标随复权切换自动重算(value 取自当前数据)。
|
||||
* 早于已渲染窗口的交易先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
|
||||
* 列表为空(关闭显示/清空成交/切到无成交股票)也必须清组,否则旧标记残留。 */
|
||||
function renderTradeMarkers() {
|
||||
/** 按 groupId 整组重建事件标记(先删后建,幂等)。事件日期按时间戳吸附到所在 bar:
|
||||
* B 贴 bar.low 下方、S/T/D 贴 bar.high 上方;坐标随复权切换自动重算(value 取自当前数据)。
|
||||
* 早于已渲染窗口的事件先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
|
||||
* 列表为空(关闭显示/清空成交/切到无分红股票)也必须清组,否则旧标记残留。 */
|
||||
function renderMarkerGroup(groupId: string, markers: { key: string; ts: number; kind: 'B' | 'S' | 'T' | 'D'; rows: TradeRow[] }[] | undefined) {
|
||||
if (!chart) return;
|
||||
tradeTip.value = null; // 组重建期间字母已换位,旧明细浮层不能留在原地
|
||||
chart.removeOverlay({ groupId: TRADE_GROUP });
|
||||
if (!props.tradeMarkers?.length) return;
|
||||
chart.removeOverlay({ groupId });
|
||||
if (!markers?.length) return;
|
||||
const list = chart.getDataList();
|
||||
if (list.length === 0) return;
|
||||
const lastTs = list[list.length - 1].timestamp;
|
||||
const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0;
|
||||
const creates: OverlayCreate<unknown>[] = [];
|
||||
for (const m of props.tradeMarkers) {
|
||||
for (const m of markers) {
|
||||
const i = idxAtOrBefore(list, m.ts);
|
||||
if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期
|
||||
const bar = list[i];
|
||||
creates.push({
|
||||
id: `trade-${m.key}`,
|
||||
groupId: TRADE_GROUP,
|
||||
id: `${groupId}-${m.key}`,
|
||||
groupId,
|
||||
name: 'tradeMarker',
|
||||
points: [{ timestamp: bar.timestamp, value: m.kind === 'B' ? bar.low : bar.high }],
|
||||
extendData: { kind: m.kind, rows: m.rows },
|
||||
@@ -615,8 +620,14 @@ function renderTradeMarkers() {
|
||||
if (creates.length) chart.createOverlay(creates);
|
||||
}
|
||||
|
||||
// ---------- 交易点悬停明细(悬停 B/S/T 字母才显示,离开/滚动即隐) ----------
|
||||
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T'; date: string; rows: TradeRow[] }
|
||||
/** 全部事件标记重画(买卖点 + 分红),触发点:build 尾部 / serveOlder 左扩 / props 变化 / 清画线 */
|
||||
function renderMarkers() {
|
||||
renderMarkerGroup(TRADE_GROUP, props.tradeMarkers);
|
||||
renderMarkerGroup(DIVIDEND_GROUP, props.dividendMarkers?.map((m) => ({ ...m, kind: 'D' as const })));
|
||||
}
|
||||
|
||||
// ---------- 事件标记悬停明细(悬停 B/S/T/D 字母才显示,离开/滚动即隐) ----------
|
||||
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T' | 'D'; date: string; rows: TradeRow[] }
|
||||
const tradeTip = ref<TradeTip | null>(null);
|
||||
|
||||
/** 贴鼠标定位并在右缘/下缘自动翻转(与十字线浮层 placeHover 同款策略,宽度略大) */
|
||||
@@ -669,7 +680,7 @@ function build() {
|
||||
const start = allData.length - served - take;
|
||||
served += take;
|
||||
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
|
||||
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
|
||||
renderMarkers(); // 窗口左扩后补画此前跳过的更早事件标记(交易/分红)
|
||||
};
|
||||
const answerEmpty = () => callback([], { forward: false, backward: false });
|
||||
if (type === 'init') {
|
||||
@@ -764,8 +775,8 @@ function build() {
|
||||
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
|
||||
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
|
||||
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
|
||||
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
|
||||
renderTradeMarkers();
|
||||
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的事件标记
|
||||
renderMarkers();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
@@ -780,8 +791,8 @@ function teardown() {
|
||||
onMounted(build);
|
||||
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.tradeMarkers, renderTradeMarkers, { deep: true });
|
||||
// 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线)
|
||||
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers, { deep: true });
|
||||
// 涨跌配色切换:重建图表以应用新颜色
|
||||
watch(() => settings.priceTone, () => { teardown(); build(); });
|
||||
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
import { getMarketOverview } from '@/api/client';
|
||||
import type { IndexQuote, MarketOverview as Overview } from '@/api/types';
|
||||
import type { MarketOverview as Overview } from '@/api/types';
|
||||
|
||||
import AmountHistoryChart from '@/components/AmountHistoryChart.vue';
|
||||
import IndexKLine from '@/components/IndexKLine.vue';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
|
||||
// 主页大盘总览:A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
|
||||
// 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。
|
||||
// 主页大盘总览:中美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
|
||||
// 港股与国际指数在 /indexes 国际指数页。数据为 EOD 口径,进页面拉一次 + 手动刷新即可。
|
||||
|
||||
const overview = ref<Overview | null>(null);
|
||||
const loading = ref(false);
|
||||
@@ -31,7 +32,7 @@ const groups = computed(() => {
|
||||
const idx = overview.value?.indexes ?? [];
|
||||
return [
|
||||
{ label: '沪深主要指数', items: idx.filter((i) => i.region === 'cn') },
|
||||
{ label: '港美市场', items: idx.filter((i) => i.region !== 'cn') },
|
||||
{ label: '美股市场', items: idx.filter((i) => i.region === 'us') },
|
||||
].filter((g) => g.items.length > 0);
|
||||
});
|
||||
|
||||
@@ -72,50 +73,6 @@ function fmtYi(v: number | null | undefined): string {
|
||||
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2)} 万亿`;
|
||||
return `${v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} 亿`;
|
||||
}
|
||||
|
||||
// ---------- 迷你走势(归一化折线,SVG 拉伸 + 描边不缩放;端点/悬停点用 HTML 圆点保证正圆) ----------
|
||||
const SPARK_PAD = 0.08; // 上下留白,避免贴边
|
||||
|
||||
function sparkGeom(it: IndexQuote) {
|
||||
const vals = it.spark;
|
||||
const n = vals.length;
|
||||
if (n < 2) return null;
|
||||
const lo = Math.min(...vals);
|
||||
const hi = Math.max(...vals);
|
||||
const span = hi - lo || Math.abs(hi) || 1;
|
||||
const pts = vals.map((v, i) => ({
|
||||
x: i / (n - 1),
|
||||
y: 1 - ((v - lo) / span) * (1 - 2 * SPARK_PAD) - SPARK_PAD,
|
||||
}));
|
||||
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(4)},${p.y.toFixed(4)}`).join(' ');
|
||||
const area = `${line} L1,1 L0,1 Z`;
|
||||
return { pts, line, area, last: pts[n - 1] };
|
||||
}
|
||||
|
||||
const sparkColor = (it: IndexQuote): string => {
|
||||
if (it.pct_chg == null || it.pct_chg === 0) return '#A8AFB8';
|
||||
return it.pct_chg > 0 ? 'var(--color-up)' : 'var(--color-down)';
|
||||
};
|
||||
|
||||
// 悬停:per-code 记录索引 + 相对坐标,tooltip 跟随
|
||||
const hover = reactive<Record<string, { i: number; x: number; y: number }>>({});
|
||||
|
||||
function onSparkMove(it: IndexQuote, e: MouseEvent) {
|
||||
const g = sparkGeom(it);
|
||||
if (!g) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const t = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
const i = Math.round(t * (it.spark.length - 1));
|
||||
hover[it.code] = { i, x: g.pts[i].x, y: g.pts[i].y };
|
||||
}
|
||||
|
||||
function hoverText(it: IndexQuote): string {
|
||||
const h = hover[it.code];
|
||||
if (!h) return '';
|
||||
const d = it.spark_dates[h.i] ?? '';
|
||||
const iso = d ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '';
|
||||
return `${iso} ${fmtClose(it.spark[h.i])}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,6 +83,13 @@ function hoverText(it: IndexQuote): string {
|
||||
</h2>
|
||||
<div class="flex items-center gap-3 text-xs text-[#6B7280]">
|
||||
<span v-if="updatedAt">更新于 {{ updatedAt }}</span>
|
||||
<RouterLink
|
||||
to="/indexes"
|
||||
class="flex items-center gap-1 rounded-md border border-[#26272E] px-2 py-1 transition-colors hover:border-[#3A3D46] hover:text-[#A8AFB8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
|
||||
国际指数
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 transition-colors hover:bg-[#26272E] hover:text-[#A8AFB8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
@@ -182,54 +146,12 @@ function hoverText(it: IndexQuote): string {
|
||||
{{ it.pct_chg != null && it.pct_chg > 0 ? '▲' : it.pct_chg != null && it.pct_chg < 0 ? '▼' : '' }}{{ fmtPct(it.pct_chg) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 迷你走势:默认高度 36px,hover 出十字点与数值 -->
|
||||
<div
|
||||
class="relative mt-2 h-9"
|
||||
@mousemove="onSparkMove(it, $event)"
|
||||
@mouseleave="delete hover[it.code]"
|
||||
>
|
||||
<svg
|
||||
v-if="sparkGeom(it)"
|
||||
class="h-full w-full"
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path :d="sparkGeom(it)!.area" :fill="sparkColor(it)" fill-opacity="0.1" />
|
||||
<path
|
||||
:d="sparkGeom(it)!.line"
|
||||
fill="none"
|
||||
:stroke="sparkColor(it)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<!-- 端点(带 2px 表面环)与悬停点:HTML 圆点,避免非等比 viewBox 把圆拉成椭圆 -->
|
||||
<span
|
||||
v-if="sparkGeom(it)"
|
||||
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
|
||||
:style="{ left: `${sparkGeom(it)!.last.x * 100}%`, top: `${sparkGeom(it)!.last.y * 100}%`, backgroundColor: sparkColor(it) }"
|
||||
/>
|
||||
<template v-if="hover[it.code]">
|
||||
<span
|
||||
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
|
||||
:style="{ left: `${hover[it.code].x * 100}%`, top: `${hover[it.code].y * 100}%`, backgroundColor: sparkColor(it) }"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded border border-[#3A3D46] bg-[#1A1B21] px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-[#E5E7EB]"
|
||||
:style="{ left: `${Math.min(82, Math.max(18, hover[it.code].x * 100))}%` }"
|
||||
>{{ hoverText(it) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 迷你走势:hover 出十字点与数值 -->
|
||||
<Sparkline class="mt-2" :values="it.spark" :dates="it.spark_dates" :pct="it.pct_chg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上证指数 K 线(日/周/月/年,周期随用户偏好持久化) -->
|
||||
<IndexKLine class="mt-4" />
|
||||
|
||||
<!-- 两市统计:daily_info 沪 SH_MARKET(主板A+科创+B) + 深 SZ_MARKET 汇总 -->
|
||||
<div
|
||||
v-if="overview.stats"
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
import { useMarketSyncStore } from '@/stores/marketSync';
|
||||
import { useEtfSyncStore } from '@/stores/etfSync';
|
||||
|
||||
const store = useMarketSyncStore();
|
||||
const etfStore = useEtfSyncStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchStatus();
|
||||
await Promise.all([store.fetchStatus(), etfStore.fetchStatus()]);
|
||||
store.pollIfRunning();
|
||||
etfStore.pollIfRunning();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
store.stopPolling();
|
||||
etfStore.stopPolling();
|
||||
});
|
||||
onBeforeUnmount(() => store.stopPolling());
|
||||
|
||||
/** "2026-09-01T00:00:00" / "2026-09-01" -> "2026年09月01日";无数据显示 — */
|
||||
function fmtDate(s?: string | null): string {
|
||||
@@ -21,20 +27,35 @@ function fmtDate(s?: string | null): string {
|
||||
}
|
||||
|
||||
const latestDate = computed(() => store.syncStatus?.last_trade_date ?? null);
|
||||
const running = computed(() => !!store.syncStatus?.running);
|
||||
const etfLatestDate = computed(() => etfStore.syncStatus?.last_trade_date ?? null);
|
||||
const marketRunning = computed(() => !!store.syncStatus?.running);
|
||||
const etfRunning = computed(() => !!etfStore.syncStatus?.running);
|
||||
// 任一在同步即视为「同步中」(隐藏按钮,避免同步期间重复触发)
|
||||
const running = computed(() => marketRunning.value || etfRunning.value);
|
||||
const syncing = computed(() => !!store.syncStatus?.total_days);
|
||||
// 后端任务内错误(如 daily_basic 权限受限)与请求级错误都展示
|
||||
const errText = computed(() => store.error || store.syncStatus?.error || null);
|
||||
const etfErrText = computed(() => etfStore.error || etfStore.syncStatus?.error || null);
|
||||
const progressPct = computed(() => {
|
||||
const s = store.syncStatus;
|
||||
if (!s?.total_days) return 0;
|
||||
return Math.min(100, ((s.done_days ?? 0) / s.total_days) * 100);
|
||||
});
|
||||
const etfProgressPct = computed(() => {
|
||||
const s = etfStore.syncStatus;
|
||||
if (!s?.total) return 0;
|
||||
return Math.min(100, ((s.done ?? 0) / s.total) * 100);
|
||||
});
|
||||
|
||||
/** 一个按钮同时同步 A 股与 ETF(两端点各自幂等;已在这边跑着的不会被重复启动) */
|
||||
async function startAll() {
|
||||
await Promise.all([store.startSync(), etfStore.startSync()]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-12 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-[#26272E] bg-[#101014] px-5 py-4">
|
||||
<!-- 最新更新日期 -->
|
||||
<!-- 最新更新日期:A股 + ETF -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -48,29 +69,53 @@ const progressPct = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="hidden flex-1 sm:block"></span>
|
||||
|
||||
<!-- 同步中:进度条 -->
|
||||
<div v-if="running" class="flex min-w-[220px] flex-1 items-center gap-2">
|
||||
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
|
||||
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">({{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }})</template>
|
||||
</span>
|
||||
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
|
||||
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
|
||||
<path d="M22 12A10 10 0 0 0 12 2v10h10z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-xs text-[#6B7280]">ETF日线数据更新至</div>
|
||||
<div class="text-base font-semibold tabular-nums text-[#E5E7EB]">{{ fmtDate(etfLatestDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空闲:手动同步按钮 -->
|
||||
<button v-else type="button" class="btn-primary shrink-0" @click="store.startSync()">
|
||||
<span class="hidden flex-1 sm:block"></span>
|
||||
|
||||
<!-- 同步中:A股 + ETF 各自进度 -->
|
||||
<div v-if="running" class="flex min-w-[220px] flex-1 flex-col gap-1.5">
|
||||
<div v-if="marketRunning" class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
|
||||
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">({{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }})</template>
|
||||
</span>
|
||||
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
|
||||
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="etfRunning" class="flex items-center gap-2">
|
||||
<svg class="h-3.5 w-3.5 shrink-0 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
|
||||
ETF:{{ etfStore.syncStatus?.step || '同步中…' }}<template v-if="etfStore.syncStatus?.total">({{ etfStore.syncStatus?.done }}/{{ etfStore.syncStatus?.total }})</template>
|
||||
</span>
|
||||
<span v-if="etfStore.syncStatus?.total" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
|
||||
<span class="block h-full rounded-full bg-violet-500 transition-all" :style="{ width: etfProgressPct + '%' }" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空闲:手动同步按钮(一次同步 A 股 + ETF) -->
|
||||
<button v-else type="button" class="btn-primary shrink-0" @click="startAll()">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
|
||||
同步A股当日数据
|
||||
同步A股/ETF当日数据
|
||||
</button>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errText" class="w-full text-sm text-amber-400">
|
||||
<div v-if="errText || etfErrText" class="w-full text-sm text-amber-400">
|
||||
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||
{{ errText }}
|
||||
<template v-if="errText">A股:{{ errText }}<template v-if="etfErrText">;</template></template><template v-if="etfErrText">ETF:{{ etfErrText }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
|
||||
addWatchlist, clearTrades, getStockDividends, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
|
||||
importTrades, removeWatchlist,
|
||||
} from '@/api/client';
|
||||
import type {
|
||||
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField,
|
||||
TradesImportResponse, UserTrade,
|
||||
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, StockDividendRecord, StockFinanceRecord,
|
||||
Timeframe, TooltipField, TradesImportResponse, UserTrade,
|
||||
} from '@/api/types';
|
||||
import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
|
||||
import DetailKLine from './DetailKLine.vue';
|
||||
import CompanyInfoPanel from './CompanyInfoPanel.vue';
|
||||
import FinancePanel from './FinancePanel.vue';
|
||||
import ReferencePanel from './ReferencePanel.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: ScreenerItemOut[];
|
||||
@@ -71,6 +74,9 @@ 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 }); }
|
||||
// K线分红标记(默认开;与交易点同款持久化开关)
|
||||
const showDividends = computed<boolean>(() => layout.value.showDividends !== false);
|
||||
function toggleDividends() { settings.setChartLayout({ showDividends: !showDividends.value }); }
|
||||
// 知行指标图例行的板块文本(通达信 HYBLOCK+' '+DYBLOCK 的近似;GNBLOCK 概念板块无数据源)
|
||||
const zhixingBlocks = computed(() => {
|
||||
const info = data.value?.info;
|
||||
@@ -230,9 +236,30 @@ async function loadTrades(code: string) {
|
||||
if (token === tradesToken) trades.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 分红(tushare dividend 懒加载;K线除权标记 + 右栏统计) ----------
|
||||
const dividends = ref<StockDividendRecord[]>([]);
|
||||
/** FinancePanel 加载成功后上报的财务记录(近五年,报告期倒序);分红率等跨源指标用 */
|
||||
const financeRecords = ref<StockFinanceRecord[]>([]);
|
||||
|
||||
/** 拉当前股分红(失败静默:未登录/网络异常都不影响看图)。与 loadTrades 同款请求序号防乱序。 */
|
||||
let dividendsToken = 0;
|
||||
async function loadDividends(code: string) {
|
||||
const token = ++dividendsToken;
|
||||
try {
|
||||
const res = await getStockDividends(code);
|
||||
if (token === dividendsToken) dividends.value = res.records;
|
||||
} catch {
|
||||
if (token === dividendsToken) dividends.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(active, (code) => {
|
||||
trades.value = []; // 同步先清:新图挂载时(成交未返回)不能带着旧股标记
|
||||
dividends.value = [];
|
||||
financeRecords.value = [];
|
||||
loadTrades(code);
|
||||
loadDividends(code);
|
||||
}, { immediate: true });
|
||||
|
||||
const fmtQty = (q: number) =>
|
||||
@@ -275,6 +302,79 @@ const tradeMarkers = computed(() => {
|
||||
}).sort((a, b) => a.ts - b.ts);
|
||||
});
|
||||
|
||||
/** YYYYMMDD → YYYY-MM-DD(分红各日期字段的展示格式) */
|
||||
const fmtYmd8 = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||
const fmt4 = (v: number) => String(+v.toFixed(4));
|
||||
|
||||
/** 按除权除息日聚成分红标记(kind 固定 'D' 贴 high 上方):只取已实施行;同日多笔
|
||||
* (如年度+中期分红同日除权)合并金额。明细收进 rows,悬停字母 D 时显示。 */
|
||||
const dividendMarkers = computed(() => {
|
||||
if (!showDividends.value) return [];
|
||||
const byEx = new Map<string, {
|
||||
ts: number; cash: number; cashTax: number; bo: number; co: number;
|
||||
years: Set<string>; record: string | null; pay: string | null;
|
||||
}>();
|
||||
for (const d of dividends.value) {
|
||||
if (d.div_proc !== '实施' || !d.ex_date || d.ex_date.length !== 8) continue;
|
||||
let v = byEx.get(d.ex_date);
|
||||
if (!v) byEx.set(d.ex_date, v = {
|
||||
ts: new Date(+d.ex_date.slice(0, 4), +d.ex_date.slice(4, 6) - 1, +d.ex_date.slice(6, 8)).getTime(),
|
||||
cash: 0, cashTax: 0, bo: 0, co: 0, years: new Set(), record: null, pay: null,
|
||||
});
|
||||
v.cash += d.cash_div ?? 0;
|
||||
v.cashTax += d.cash_div_tax ?? 0;
|
||||
v.bo += d.stk_bo_rate ?? 0;
|
||||
v.co += d.stk_co_rate ?? 0;
|
||||
if (d.end_date) v.years.add(d.end_date.slice(0, 4));
|
||||
v.record = v.record ?? (d.record_date ? fmtYmd8(d.record_date) : null);
|
||||
v.pay = v.pay ?? (d.pay_date ? fmtYmd8(d.pay_date) : null);
|
||||
}
|
||||
return [...byEx.entries()].map(([ex, v]) => {
|
||||
const rows: { label: string; text: string; tone: 'buy' | 'sell' | '' }[] = [];
|
||||
if (v.cash > 0) rows.push({ label: '每股派息(税后)', text: `${fmt4(v.cash)} 元`, tone: '' });
|
||||
if (v.cashTax > 0 && Math.abs(v.cashTax - v.cash) > 1e-9) rows.push({ label: '每股派息(税前)', text: `${fmt4(v.cashTax)} 元`, tone: '' });
|
||||
if (v.bo > 0 || v.co > 0) {
|
||||
rows.push({ label: '每股送转', text: `10送${fmt4(v.bo * 10)}转${fmt4(v.co * 10)}`, tone: '' });
|
||||
}
|
||||
if (v.years.size) rows.push({ label: '分红年度', text: [...v.years].sort().join(' / '), tone: '' });
|
||||
if (v.record) rows.push({ label: '股权登记日', text: v.record, tone: '' });
|
||||
if (v.pay) rows.push({ label: '派息日', text: v.pay, tone: '' });
|
||||
return { key: fmtYmd8(ex), ts: v.ts, rows };
|
||||
}).sort((a, b) => a.ts - b.ts);
|
||||
});
|
||||
|
||||
/** 右栏分红统计:股息率(TTM) / 分红率(最近有分红的年报年度) / 近5年次数与累计。
|
||||
* 分红率 = 该年度每股税前分红合计 ÷ 该年年报基本每股收益(finance 数据由 FinancePanel 上报)。 */
|
||||
const divStats = computed(() => {
|
||||
const impl = dividends.value.filter((d) => d.div_proc === '实施');
|
||||
if (impl.length === 0) return null;
|
||||
const now = Date.now();
|
||||
const ttmCutoff = now - 365 * 86400000;
|
||||
const fiveCutoff = now - 5 * 365 * 86400000;
|
||||
let ttmCash = 0, cnt5y = 0, sum5y = 0;
|
||||
for (const d of impl) {
|
||||
if (!d.ex_date || d.ex_date.length !== 8) continue;
|
||||
const ts = new Date(+d.ex_date.slice(0, 4), +d.ex_date.slice(4, 6) - 1, +d.ex_date.slice(6, 8)).getTime();
|
||||
if (ts > now) continue; // 已公告未除权的不计
|
||||
if (ts >= ttmCutoff) ttmCash += d.cash_div ?? 0;
|
||||
if (ts >= fiveCutoff) { cnt5y += 1; sum5y += d.cash_div ?? 0; }
|
||||
}
|
||||
const price = data.value?.info?.close;
|
||||
const yieldTtm = ttmCash > 0 && price ? (ttmCash / price) * 100 : null;
|
||||
// 分红率:从最新年报年度往前找第一个有分红实施的年度
|
||||
let payout: number | null = null;
|
||||
const annuals = financeRecords.value.filter((r) => r.end_date.endsWith('1231'));
|
||||
for (const a of annuals) {
|
||||
const y = a.end_date.slice(0, 4);
|
||||
const divSum = impl.filter((d) => (d.end_date ?? '').startsWith(y)).reduce((s, d) => s + (d.cash_div_tax ?? 0), 0);
|
||||
if (divSum > 0) {
|
||||
if (a.eps && a.eps > 0) payout = (divSum / a.eps) * 100;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { yieldTtm, payout, cnt5y, sum5y };
|
||||
});
|
||||
|
||||
async function onTradeFile(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
@@ -436,27 +536,22 @@ onBeforeUnmount(() => {
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// ---------- 右侧信息栏增强:52周高低 / 年初至今(从日线序列算,无数据留空) ----------
|
||||
// ---------- 右侧信息栏增强:52周高低(从日线序列算,无数据留空) ----------
|
||||
const stats = computed(() => {
|
||||
// 日期跳转后窗口是历史段,52周/年初至今口径失真,直接不显示
|
||||
// 日期跳转后窗口是历史段,52周口径失真,直接不显示
|
||||
const bars = timeframe.value === '1d' && jumpTs.value == null ? data.value?.candles : null;
|
||||
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
|
||||
if (!bars || bars.length === 0) return { high52: null, low52: null };
|
||||
const last = bars[bars.length - 1];
|
||||
const lastTs = new Date(last.ts);
|
||||
const yearStart = new Date(lastTs.getFullYear(), 0, 1).getTime();
|
||||
let high = -Infinity, low = Infinity;
|
||||
let ytdBase: number | null = null;
|
||||
const cutoff = lastTs.getTime() - 365 * 24 * 3600 * 1000;
|
||||
for (const b of bars) {
|
||||
const t = new Date(b.ts).getTime();
|
||||
if (t >= cutoff) { high = Math.max(high, b.high); low = Math.min(low, b.low); }
|
||||
// 年初至今基准 = 上一年最后一根收盘
|
||||
if (t < yearStart) ytdBase = b.close;
|
||||
}
|
||||
return {
|
||||
high52: high === -Infinity ? null : high,
|
||||
low52: low === Infinity ? null : low,
|
||||
ytd: ytdBase && ytdBase !== 0 ? ((last.close - ytdBase) / ytdBase) * 100 : null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -606,6 +701,14 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
:title="trades.length ? `本股实盘成交 ${trades.length} 笔(交割单导入)` : '本股暂无实盘成交记录,点击导入交割单'"
|
||||
@click="trades.length ? (showTrades = !showTrades) : openTradeImport()"
|
||||
>交易点{{ trades.length ? ` ${trades.length}` : '' }}</button>
|
||||
<!-- 分红除权标记(tushare dividend) -->
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
|
||||
:class="showDividends && dividendMarkers.length ? 'border-[#9C6ADE] bg-[#9C6ADE] text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
|
||||
:title="dividendMarkers.length ? `分红除权 ${dividendMarkers.length} 次(悬停 D 看方案)` : '本股无已实施分红记录'"
|
||||
@click="toggleDividends"
|
||||
>分红{{ dividendMarkers.length ? ` ${dividendMarkers.length}` : '' }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
|
||||
@@ -732,14 +835,15 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
:tooltip-fields="tooltipFields"
|
||||
:center-ts="jumpTs"
|
||||
:trade-markers="tradeMarkers"
|
||||
:dividend-markers="dividendMarkers"
|
||||
@center-miss="onCenterMiss"
|
||||
/>
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">无数据</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右:个股信息(通达信式) -->
|
||||
<aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-[#26272E] bg-[#101014] p-4">
|
||||
<!-- 右:个股信息(通达信式);w-96=384px:参考数据/财务的多列表格在 w-72 下换行过碎 -->
|
||||
<aside v-if="data" class="w-96 shrink-0 overflow-y-auto border-l border-[#26272E] bg-[#101014] p-4">
|
||||
<div class="border-b border-[#1E2026] pb-3">
|
||||
<div class="text-[15px] font-semibold text-[#E8EAED]">{{ data.info.name }}</div>
|
||||
<div class="mt-0.5 text-[13px] text-[#9BA3AE]">
|
||||
@@ -756,8 +860,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-y-2 text-sm">
|
||||
<template v-for="(row, i) in [
|
||||
['今开', fmt(data.info.open)],
|
||||
['昨收', fmt(data.info.pre_close)],
|
||||
['最高', fmt(data.info.high)],
|
||||
['最低', fmt(data.info.low)],
|
||||
['成交量', fmtInt(data.info.volume_hand) + ' 手'],
|
||||
@@ -769,7 +871,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
|
||||
['52周最高', fmt(stats.high52)],
|
||||
['52周最低', fmt(stats.low52)],
|
||||
['年初至今', stats.ytd == null ? '—' : (stats.ytd > 0 ? '+' : '') + stats.ytd.toFixed(2) + '%'],
|
||||
['上市日期', fmtListDate(data.info.list_date)],
|
||||
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
|
||||
]" :key="i">
|
||||
@@ -778,22 +879,31 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 股本/分红/股东(数据未接入前留空占位) -->
|
||||
<div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
|
||||
<div class="mb-2 text-[13px] text-[#9BA3AE]">股本 / 分红 / 股东</div>
|
||||
<!-- 分红统计(tushare dividend;无已实施分红整节隐藏) -->
|
||||
<div v-if="divStats" class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
|
||||
<div class="mb-2 text-[13px] text-[#9BA3AE]">分红</div>
|
||||
<div class="grid grid-cols-2 gap-y-2">
|
||||
<template v-for="(row, i) in [
|
||||
['股东户数', '—'],
|
||||
['户均持股', '—'],
|
||||
['分红率', '—'],
|
||||
['股息率', '—'],
|
||||
['股息率TTM', divStats.yieldTtm == null ? '—' : divStats.yieldTtm.toFixed(2) + '%'],
|
||||
['分红率', divStats.payout == null ? '—' : divStats.payout.toFixed(1) + '%'],
|
||||
['近5年分红', `${divStats.cnt5y} 次`],
|
||||
['近5年每股累计', divStats.sum5y > 0 ? fmt4(divStats.sum5y) + ' 元' : '—'],
|
||||
]" :key="i">
|
||||
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
|
||||
<span class="text-right text-[#C3C9D2]" title="数据源待接入">{{ row[1] }}</span>
|
||||
<span class="text-right text-[#E8EAED]">{{ row[1] }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 财务指标(fina_indicator+三大报表关键值,近五年;:key 切股重挂,加载成功上报记录供分红率计算) -->
|
||||
<FinancePanel :key="data.ts_code" :ts-code="data.ts_code" @loaded="financeRecords = $event" />
|
||||
|
||||
<!-- 参考数据(tushare 参考数据版块 11 类;默认折叠,:key 切股重挂自动清缓存) -->
|
||||
<ReferencePanel :key="data.ts_code" :ts-code="data.ts_code" />
|
||||
|
||||
<!-- 公司简介(tushare stock_company 懒加载;:key 切股重挂,折叠态/数据自动重置) -->
|
||||
<CompanyInfoPanel :key="data.ts_code" :ts-code="data.ts_code" />
|
||||
|
||||
<div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
|
||||
<div class="mb-2 text-[13px] text-[#9BA3AE]">归属</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -9,6 +9,9 @@ const router = createRouter({
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
|
||||
{ path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') },
|
||||
{ path: '/etfs', name: 'etfs', component: () => import('@/views/EtfsView.vue') },
|
||||
{ path: '/indexes', name: 'indexes', component: () => import('@/views/IndexesView.vue') },
|
||||
{ path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') },
|
||||
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
|
||||
@@ -71,6 +71,7 @@ function loadLayout(): ChartLayoutPrefs {
|
||||
tooltipFields: normTipFields(v.tooltipFields) ?? DEFAULT_TOOLTIP_FIELDS,
|
||||
showBoll: v.showBoll === true,
|
||||
showZhixing: v.showZhixing === true,
|
||||
showDividends: v.showDividends !== false,
|
||||
indexTimeframe: normTimeframe(v.indexTimeframe),
|
||||
};
|
||||
}
|
||||
@@ -180,6 +181,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
tooltipFields: tip,
|
||||
showBoll: v.showBoll === true,
|
||||
showZhixing: v.showZhixing === true,
|
||||
showDividends: v.showDividends !== false,
|
||||
indexTimeframe: normTimeframe(v.indexTimeframe),
|
||||
};
|
||||
saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RouterLink } from 'vue-router';
|
||||
import MarketOverview from '@/components/MarketOverview.vue';
|
||||
import MarketSyncBar from '@/components/MarketSyncBar.vue';
|
||||
|
||||
// 首页:大盘行情总览 + 三大功能入口(看股 / 选股 / 回测)
|
||||
// 首页:大盘行情总览 + 功能入口(看股 / ETF / 选股 / 回测)
|
||||
const features = [
|
||||
{
|
||||
to: '/stocks',
|
||||
@@ -13,6 +13,13 @@ const features = [
|
||||
title: '看股',
|
||||
desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。',
|
||||
},
|
||||
{
|
||||
to: '/etfs',
|
||||
icon: 'M21.21 15.89A10 10 0 1 1 8 2.83M22 12A10 10 0 0 0 12 2v10h10z',
|
||||
accent: 'bg-violet-500/15 text-violet-300',
|
||||
title: 'ETF',
|
||||
desc: '浏览全市场 1,500+ 只场内 ETF 的行情、规模与 K 线。',
|
||||
},
|
||||
{
|
||||
to: '/screener',
|
||||
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
|
||||
@@ -31,12 +38,12 @@ const features = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-5xl">
|
||||
<div class="w-full max-w-6xl">
|
||||
<MarketOverview />
|
||||
|
||||
<MarketSyncBar />
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<RouterLink
|
||||
v-for="f in features"
|
||||
:key="f.to"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
$ vite
|
||||
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m1900[22m[2m[0m ms[22m
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m1749[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
[2m18:31:29[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/types.ts[22m
|
||||
[2m18:31:29[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m18:31:31[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m18:31:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css[22m
|
||||
[2m18:31:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css[22m
|
||||
|
||||
Reference in New Issue
Block a user