提交
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user