This commit is contained in:
2026-09-07 13:34:26 +08:00
parent ad9245abdd
commit 359f9ae2e4
23 changed files with 2260 additions and 513 deletions

View File

@@ -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:
"""公司简介:库内有新鲜行直返;否则锁内单查 tusharestock_company并 upsert行即缓存
30 天新鲜度无此股写墓碑负缓存。ETF 前置短路;确认无数据 404tushare 失败且
无旧行可降级时 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合并 upsert7 天新鲜度,无数据写墓碑)。
ETF 前置短路;确认无数据 404tushare 四源全失败且无旧行可降级时 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 404tushare 失败且无旧行可降级时 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 最新 barLATERAL必须在分页前 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}(白名单,其他值回落 symbolorder ∈ 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 日 sparkSWR 缓存)。"""
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_global1d/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)

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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_dailyquicksync 镜像可用;与股票同源同控频),
# 按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 日线(同步网络 IOto_thread 调用)。
单位沿用 Tusharevol 手、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

View File

@@ -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 层失败负缓存(秒)

View File

@@ -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 位纯代码前缀判断是否场内 ETFfetcher 据此路由到东财数据源)。"""
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")):

View File

@@ -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.SHsymbol 为 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_indicatordoc 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) # 研发投入(元)
# ---- incomedoc 33report_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) # 归母净利润(元)
# ---- balancesheetreport_type=1----
total_assets: Mapped[float | None] = mapped_column(Float) # 总资产(元)
total_hldr_eqy: Mapped[float | None] = mapped_column(Float) # 归母股东权益(元)
# ---- cashflowreport_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_dateK 线标记与统计只用实施行。
"""
__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) 隔离)。

View File

@@ -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] = []

View File

@@ -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 底座同时容纳 ETFetf_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_dailymax(ts),走索引很快)保持每次实时——它是 UI 主展示字段。
_status_stats_cache: dict = {"at": 0.0, "ver": -1, "data": None}
_STATS_TTL = 120.0 # 进程内兜底 TTLRedis 不可用时重聚合的最小间隔)
_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_dailymax(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")
)

View File

@@ -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含场内 ETF51/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: