Files
stock/backend/app/api.py
2026-08-16 21:24:41 +08:00

985 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP 路由OpenAPI 契约的载体)。
GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
GET /api/market/overview 主页大盘总览A 股/港美指数 + 两市市值成交统计)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
POST /api/screener/sync 启动全市场数据同步(后台任务)
GET /api/screener/sync/status 同步任务状态与数据实况
"""
from __future__ import annotations
import bisect
import json
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import TextClause
from .backtest.engine import BacktestConfig, run_backtest
from . import cache
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 fetcher, repository
from .data.aggregation import bars_per_year, resample_bars
from .data.market_overview import MarketOverviewError, fetch_overview
from .data.symbols import plain_code
from .db import get_session
from .domain import Bar
from . import indicators as ind
from .trades import parse_statement
from .models import (
AdjFactor,
BacktestRun,
Candle,
DailySnapshot,
ScreenerQuery,
StockBasic,
UserPreference,
UserTrade,
WatchlistItem,
)
from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
EquityPoint,
EventBacktestRequest,
EventBacktestResponse,
IndicatorOut,
MarketOverviewResponse,
MetricsOut,
PreferencesOut,
PreferencesUpdate,
PreviewInfoOut,
PreviewResponse,
ScreenerQueryListResponse,
ScreenerQueryOut,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
SignalOut,
StockListItemOut,
StockListResponse,
StockFacetsResponse,
FacetItemOut,
SyncRequest,
SyncResponse,
TradesClearResponse,
TradesImportResponse,
UserTradeOut,
WatchlistOp,
)
from .screener import engine, market_sync
from .screener.engine import DataNotReadyError
from .screener.llm import ScreenerError, parse_conditions, parse_event_spec
router = APIRouter(prefix="/api", dependencies=[Depends(require_user)])
def _series_to_jsonable(s: pd.Series) -> list[float | None]:
"""NaN -> Nonelightweight-charts 的 whitespace data跳过指标预热期"""
out: list[float | None] = []
for v in s.tolist():
if v is None or (isinstance(v, float) and v != v):
out.append(None)
else:
out.append(float(v))
return out
def _rows_to_bars(rows) -> list[Bar]:
return [
Bar(
ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume,
amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None),
)
for r in rows
]
_ADJUST_MODES = ("bfq", "qfq", "hfq")
# MA 全量集合(前端已改为本地计算 MA后端始终返回此集合以保证缓存一致
_FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]:
"""按复权因子把 K 线从 from_mode 换算到 to_modebfq/qfq/hfq
相对不复权的乘数bfq=1qfq=f(t)/f(latest)hfq=f(t)。
因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。
"""
fd = sorted((f.trade_date.date(), float(f.adj_factor)) for f in factors)
fdates = [d for d, _ in fd]
f_latest = fd[-1][1]
def _f_at(d) -> float:
i = bisect.bisect_right(fdates, d) - 1
return fd[i][1] if i >= 0 else fd[0][1]
def _mult(mode: str, f: float) -> float:
if mode == "bfq":
return 1.0
return f / f_latest if mode == "qfq" else f
out: list[Bar] = []
for b in bars:
f = _f_at(b.ts.date())
m = _mult(to_mode, f) / _mult(from_mode, f)
out.append(Bar(
ts=b.ts,
open=round(b.open * m, 3), high=round(b.high * m, 3),
low=round(b.low * m, 3), close=round(b.close * m, 3),
volume=b.volume,
# 成交额/换手率是名义量,不随复权缩放
amount=b.amount, turnover=b.turnover,
))
return out
@router.get("/candles/{symbol}", response_model=list[CandleOut])
async def get_candles(
symbol: str,
timeframe: str = "1d",
limit: int = 5000,
session: AsyncSession = Depends(get_session),
) -> list[CandleOut]:
# 始终以日线为基底,再聚合到目标周期(取最新 limit 根)
rows = await repository.get_recent_candles(session, symbol, "1d", limit=limit)
bars = resample_bars(_rows_to_bars(rows), timeframe)
return [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=b.turnover)
for b in bars
]
@router.post("/data/sync", response_model=SyncResponse)
async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_session)) -> SyncResponse:
"""主动拉取并缓存某标的的日线Tushare 主 -> AKShare 兜底)。"""
try:
res = await fetcher.sync_symbol(
session, req.symbol, start=req.start, end=req.end, source=req.source, force=req.force
)
return SyncResponse(**res)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=str(e))
# ---------- 股票列表(全市场浏览) ----------
# 过滤/排序/分页在 stock_basic+watchlist+daily_snapshot 上完成(快照按最新交易日
# 走唯一索引 join便宜再对「本页」≤limit 只股票补最新价/昨收LATERAL 扫
# candles——旧写法对全市场 ~5000 只逐个算,每页都白算 50 倍的行情量。
# 排序列白名单键→CTE 内表达式order_by 由白名单拼接进模板,不接收用户原文。
_STOCKS_SORTS = {
"symbol": "sb.symbol",
"total_mv": "snap.total_mv",
"circ_mv": "snap.circ_mv",
"pe_ttm": "snap.pe_ttm",
"pb": "snap.pb",
"turnover_rate": "snap.turnover_rate",
}
_STOCKS_SQL_TMPL = """
WITH page AS (
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
(w.id IS NOT NULL) AS watched,
snap.turnover_rate, snap.pe_ttm, snap.pb, snap.total_mv, snap.circ_mv
FROM stock_basic sb
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
LEFT JOIN daily_snapshot snap ON snap.ts_code = sb.ts_code
AND snap.trade_date = (SELECT max(trade_date) FROM daily_snapshot)
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
ORDER BY {order_by}
LIMIT :limit OFFSET :offset
)
SELECT p.ts_code, p.symbol, p.name, p.industry, p.market, p.watched,
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,
p.turnover_rate, p.pe_ttm, p.pb,
round((p.total_mv / 10000.0)::numeric, 2) AS total_mv,
round((p.circ_mv / 10000.0)::numeric, 2) AS circ_mv
FROM page p
LEFT JOIN LATERAL (
SELECT close, ts FROM candles
WHERE symbol = p.symbol AND timeframe = '1d'
ORDER BY ts DESC LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT close FROM candles
WHERE symbol = p.symbol AND timeframe = '1d' AND ts < c.ts
ORDER BY ts DESC LIMIT 1
) prev ON c.ts IS NOT NULL
"""
def _stocks_sql(sort: str, order: str) -> TextClause:
col = _STOCKS_SORTS.get(sort, _STOCKS_SORTS["symbol"])
direction = "DESC" if order == "desc" else "ASC"
nulls = " NULLS LAST" if col != "sb.symbol" else "" # 快照缺失/亏损无 PE 的排最后
return text(_STOCKS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
_STOCKS_COUNT_SQL = text("""
SELECT count(*) FROM stock_basic sb
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
""")
@router.get("/stocks", response_model=StockListResponse)
async def list_stocks(
search: str = "",
market: str = "",
industry: str = "",
area: 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),
) -> StockListResponse:
"""全市场股票列表stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标
(换手率/PE-TTM/PB/市值,无快照则这些列为空)。
watched_only=true 只看自选(自选有独立的「自选」分类入口,列表不再把自选排最前)。
sort ∈ {symbol,total_mv,circ_mv,pe_ttm,pb,turnover_rate}(白名单,其他值回落 symbol
order ∈ asc/desc快照列排序时缺失值无快照/亏损无 PE恒排末尾。
Redis 缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total自选增删即时失效。"""
search = search.strip()
sort = sort if sort in _STOCKS_SORTS else "symbol"
order = "desc" if order.lower() == "desc" else "asc"
limit = max(1, min(limit, 500))
offset = max(0, offset)
key = (
f"stocks:u{user.id}"
f":v{await cache.get_version(f'watchlist:{user.id}')}"
f":{cache.digest(search, market, industry, area, watched_only, sort, order, limit, offset)}"
)
cached = await cache.cache_get(key)
if cached is not None:
return StockListResponse(**cached)
params = {
"search": search,
"psearch": f"%{search}%",
"market": market,
"industry": industry,
"area": area,
"watched_only": watched_only,
"uid": user.id,
"limit": limit,
"offset": offset,
}
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
rows = (await session.execute(_stocks_sql(sort, order), params)).mappings().all()
resp = StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
await cache.cache_set(key, resp.model_dump(mode="json"), settings.stocks_cache_ttl)
return resp
@router.get("/stocks/facets", response_model=StockFacetsResponse)
async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFacetsResponse:
"""看股页筛选项:行业 / 地域含数量按数量降序。stock_basic 很少变,长缓存。"""
cached = await cache.cache_get("facets:stocks")
if cached is not None:
return StockFacetsResponse(**cached)
industries = (
await session.execute(text("""
SELECT industry AS name, count(*) AS n FROM stock_basic
WHERE list_status = 'L' AND industry IS NOT NULL AND industry <> ''
GROUP BY industry ORDER BY n DESC
"""))
).mappings().all()
areas = (
await session.execute(text("""
SELECT area AS name, count(*) AS n FROM stock_basic
WHERE list_status = 'L' AND area IS NOT NULL AND area <> ''
GROUP BY area ORDER BY n DESC
"""))
).mappings().all()
resp = StockFacetsResponse(
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas],
)
await cache.cache_set("facets:stocks", resp.model_dump(mode="json"), settings.facets_cache_ttl)
return resp
@router.get("/market/overview", response_model=MarketOverviewResponse)
async def get_market_overview() -> MarketOverviewResponse:
"""主页大盘总览A 股 + 港美指数最近收盘(含迷你走势),沪深两市市值/成交统计。
收盘口径token 无实时权限),展示时标注交易日;部分来源失败不影响其余。
"""
try:
data = await fetch_overview()
except MarketOverviewError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
return MarketOverviewResponse(**data)
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
session: AsyncSession = Depends(get_session),
) -> BacktestResponse:
# 真实数据:本地无缓存则先拉取
if not await fetcher.is_cached(session, req.symbol):
try:
await fetcher.sync_symbol(session, req.symbol, source="auto")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"数据拉取失败: {e}")
# 日线为基底,聚合到请求周期
rows = await repository.get_candles(
session, req.symbol, "1d", start=req.start, end=req.end, limit=100000
)
if not rows:
raise HTTPException(status_code=404, detail=f"无数据: symbol={req.symbol}")
bars = resample_bars(_rows_to_bars(rows), req.timeframe)
if len(bars) < 2:
raise HTTPException(status_code=400, detail=f"周期 {req.timeframe} 下数据不足,无法回测")
try:
strategy = build_strategy(req.strategy, req.params)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"策略构建失败: {e}")
cfg = BacktestConfig(
initial_cash=req.initial_cash,
fast_mode=req.fast_mode,
bars_per_year=bars_per_year(req.timeframe),
)
result = run_backtest(bars, strategy, cfg)
df: pd.DataFrame = result["df"]
m = result["metrics"]
# 记录到回测运行注册表(可复现/可审计的基础)
session.add(
BacktestRun(
symbol=req.symbol,
strategy=req.strategy,
timeframe=req.timeframe,
params_json=json.dumps(req.params, ensure_ascii=False),
initial_cash=req.initial_cash,
total_return=m["total_return"],
max_drawdown=m["max_drawdown"],
sharpe=m["sharpe"],
num_trades=m["num_trades"],
)
)
await session.commit()
candles = [
CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"],
close=r["close"], volume=r["volume"],
amount=r["amount"] if "amount" in df.columns else None,
turnover=r["turnover"] if "turnover" in df.columns else None)
for _, r in df.iterrows()
]
signals = [
SignalOut(ts=f.ts, side=f.side.value, price=f.price, qty=f.qty)
for f in result["fills"]
]
indicators = IndicatorOut(
strategy=req.strategy,
data={col: _series_to_jsonable(df[col]) for col in result["indicator_cols"]},
)
equity = [EquityPoint(ts=t.to_pydatetime(), value=float(v))
for t, v in result["equity"].items()]
return BacktestResponse(
symbol=req.symbol,
timeframe=req.timeframe,
strategy=req.strategy,
candles=candles,
indicators=indicators,
signals=signals,
equity=equity,
metrics=MetricsOut(**m),
final_cash=result["final_cash"],
final_position=result["final_position"],
initial_cash=req.initial_cash,
)
@router.post("/backtest/event", response_model=EventBacktestResponse)
async def backtest_event(
req: EventBacktestRequest,
session: AsyncSession = Depends(get_session),
) -> EventBacktestResponse:
"""自然语言事件回测:入场条件命中 -> 次日买入 -> 持有 N 日,单股或全市场汇总统计。
直传 spec 则跳过 LLM前端调参重跑"""
try:
spec = req.spec or await parse_event_spec(req.text)
result = await run_event_backtest(
session, spec,
ts_code=req.ts_code,
start=req.start.date() if req.start else None,
end=req.end.date() if req.end else None,
)
except ScreenerError as e:
raise HTTPException(status_code=502, detail=str(e))
except EventEngineError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"事件回测失败: {e}")
return EventBacktestResponse(
text=req.text,
spec=result["spec"],
universe=result["universe"],
start=result["start"],
end=result["end"],
stats=result["stats"],
trades=result["trades"],
total=result["total"],
)
# ---------- 智能选股 ----------
@router.post("/screener/run", response_model=ScreenerRunResponse)
async def screener_run(
req: ScreenerRunRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerRunResponse:
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑
成功的提问(含解析出的条件与命中数)记录到 screener_queries供历史一键重跑。"""
try:
conds = req.conditions or await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
result = await engine.run_screen(session, conds, settings.screener_default_limit)
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
exists = (
await session.execute(
select(ScreenerQuery.id).where(
ScreenerQuery.user_id == user.id,
ScreenerQuery.text == req.text.strip(),
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
)
)
).scalar_one_or_none()
if exists is None:
session.add(ScreenerQuery(
user_id=user.id,
text=req.text.strip(),
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
hit_count=result.get("total", 0),
))
await session.commit()
return ScreenerRunResponse(**result)
except HTTPException:
raise
except DataNotReadyError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e: # 未知指标/字段、条件为空
raise HTTPException(status_code=400, detail=str(e))
except ScreenerError as e:
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
raise HTTPException(status_code=code, detail=str(e))
@router.get("/screener/queries", response_model=ScreenerQueryListResponse)
async def screener_queries(
limit: int = 20,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerQueryListResponse:
"""当前用户的提问历史(最新在前,含解析出的条件与命中数,可一键重跑)。"""
limit = max(1, min(limit, 100))
rows = (
await session.execute(
select(ScreenerQuery)
.where(ScreenerQuery.user_id == user.id)
.order_by(ScreenerQuery.created_at.desc())
.limit(limit)
)
).scalars().all()
items = []
for r in rows:
conds = None
if r.conditions_json:
try:
from .schemas import ScreenConditions
conds = ScreenConditions.model_validate_json(r.conditions_json)
except Exception: # noqa: BLE001 —— 旧格式/解析失败则只展示文本
conds = None
items.append(ScreenerQueryOut(
id=r.id, text=r.text, conditions=conds, hit_count=r.hit_count, created_at=r.created_at
))
return ScreenerQueryListResponse(items=items)
@router.delete("/screener/queries/{query_id}", status_code=204)
async def screener_query_delete(
query_id: int,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> None:
await session.execute(
text("DELETE FROM screener_queries WHERE id = :i AND user_id = :u"),
{"i": query_id, "u": user.id},
)
await session.commit()
# ---------- 用户偏好 ----------
@router.get("/preferences", response_model=PreferencesOut)
async def get_preferences(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> PreferencesOut:
prefs: dict[str, object] = {}
rows = (
await session.execute(select(UserPreference).where(UserPreference.user_id == user.id))
).scalars().all()
for r in rows:
try:
prefs[r.key] = json.loads(r.value_json)
except Exception: # noqa: BLE001
prefs[r.key] = None
return PreferencesOut(prefs=prefs)
@router.put("/preferences", response_model=PreferencesOut)
async def put_preferences(
req: PreferencesUpdate,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> PreferencesOut:
"""部分更新:只覆盖出现的 key值为 null 表示删除该 key。返回更新后的全量。"""
for key, value in req.prefs.items():
if not key or len(key) > 64:
continue
if value is None:
await session.execute(
text("DELETE FROM user_preferences WHERE user_id = :u AND key = :k"),
{"u": user.id, "k": key},
)
continue
existing = (
await session.execute(
select(UserPreference).where(
UserPreference.user_id == user.id, UserPreference.key == key
)
)
).scalars().first()
vj = json.dumps(value, ensure_ascii=False)
if existing:
existing.value_json = vj
else:
session.add(UserPreference(user_id=user.id, key=key, value_json=vj))
await session.commit()
return await get_preferences(session=session, user=user)
# ---------- 自选股 ----------
@router.get("/watchlist", response_model=list[str])
async def get_watchlist(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> list[str]:
"""当前用户自选股 ts_code 列表(加入时间倒序)。"""
rows = (
await session.execute(
select(WatchlistItem.ts_code)
.where(WatchlistItem.user_id == user.id)
.order_by(WatchlistItem.created_at.desc(), WatchlistItem.id.desc())
)
).scalars().all()
return list(rows)
@router.post("/watchlist", response_model=list[str])
async def add_watchlist(
req: WatchlistOp,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
exists = (
await session.execute(
select(WatchlistItem.id).where(
WatchlistItem.user_id == user.id, WatchlistItem.ts_code == req.ts_code
)
)
).scalar_one_or_none()
if exists is None:
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
@router.delete("/watchlist/{ts_code}", response_model=list[str])
async def remove_watchlist(
ts_code: str,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
await session.execute(
text("DELETE FROM watchlist_items WHERE user_id = :u AND ts_code = :c"),
{"u": user.id, "c": ts_code},
)
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
# ---------- 交割单(个人实盘买卖点) ----------
def _trade_out(r: UserTrade) -> UserTradeOut:
return UserTradeOut(
id=r.id, ts_code=r.ts_code, name=r.name, trade_date=r.trade_date,
direction=r.direction, price=r.price, qty=r.qty, amount=r.amount, fee=r.fee,
)
@router.get("/trades", response_model=list[UserTradeOut])
async def list_trades(
ts_code: str | None = None,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[UserTradeOut]:
"""当前用户导入的实盘成交(可选 ts_code 过滤按日期升序K线买卖点数据源"""
q = (
select(UserTrade)
.where(UserTrade.user_id == user.id)
.order_by(UserTrade.trade_date, UserTrade.id)
)
if ts_code:
q = q.where(UserTrade.ts_code == ts_code)
rows = (await session.execute(q)).scalars().all()
return [_trade_out(r) for r in rows]
@router.post("/trades/import", response_model=TradesImportResponse)
async def import_trades(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesImportResponse:
"""上传券商交割单CSV/Excel/HTML 表格均可,自动识别列名),解析出买卖成交入库。
同一笔成交(同日同股同向同价同量)重复上传会跳过,重复导出幂等。
"""
data = await file.read()
if not data:
raise HTTPException(status_code=422, detail="文件是空的")
if len(data) > 20 * 1024 * 1024:
raise HTTPException(status_code=413, detail="文件超过 20MB请分时间段导出")
parsed = parse_statement(data, file.filename or "")
# 无证券代码列的导出(招商式):按证券名称反查 stock_basic 补 ts_code同名多码或查不到则弃行
unnamed = {t.name for t in parsed.trades if not t.ts_code and t.name}
if unnamed:
name_map: dict[str, str] = {}
for ts_code, name in (await session.execute(
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.name.in_(unnamed))
)).all():
name_map[name] = "" if name in name_map else ts_code
for t in parsed.trades:
if not t.ts_code and t.name:
tc = name_map.get(t.name, "")
if tc:
t.ts_code, t.code = tc, tc.split(".")[0]
else:
parsed.skipped_bad.append(f"{t.trade_date} {t.name} 名称无法唯一对应代码,未入库")
def _key(t) -> tuple:
return (t.trade_date, t.ts_code, t.direction, None if t.price is None else round(t.price, 4), round(t.qty, 4))
# Python 侧去重兜底(唯一约束对 NULL price 不生效)
existing = {
(r.trade_date, r.ts_code, r.direction, None if r.price is None else round(r.price, 4), round(r.qty, 4))
for r in (
await session.execute(
select(UserTrade.trade_date, UserTrade.ts_code, UserTrade.direction, UserTrade.price, UserTrade.qty)
.where(UserTrade.user_id == user.id, UserTrade.ts_code.in_({t.ts_code for t in parsed.trades}))
)
).all()
}
inserted: list[UserTrade] = []
seen: set[tuple] = set()
skipped_dup = 0
for t in parsed.trades:
if not t.ts_code:
continue # 名称反查失败的行,已在 bad 里说明
k = _key(t)
if k in existing or k in seen:
skipped_dup += 1
continue
seen.add(k)
inserted.append(UserTrade(
user_id=user.id, ts_code=t.ts_code, code=t.code, name=t.name or None,
trade_date=t.trade_date, direction=t.direction, price=t.price,
qty=t.qty, amount=t.amount, fee=t.fee,
raw_json=json.dumps(t.raw, ensure_ascii=False, default=str),
))
if inserted:
session.add_all(inserted)
await session.commit()
return TradesImportResponse(
inserted=len(inserted),
skipped_dup=skipped_dup,
skipped_other=parsed.skipped_other,
stocks=len({t.ts_code for t in parsed.trades}),
bad=parsed.skipped_bad[:5],
sample=[_trade_out(r) for r in inserted[:5]],
)
@router.delete("/trades", response_model=TradesClearResponse)
async def clear_trades(
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesClearResponse:
"""清空当前用户导入的全部成交(重新导入前用)。"""
res = await session.execute(delete(UserTrade).where(UserTrade.user_id == user.id))
await session.commit()
return TradesClearResponse(deleted=res.rowcount or 0)
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
async def screener_sync_start(
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
) -> ScreenerSyncStatus:
"""启动全市场数据同步(后台任务,立即返回状态)。"""
try:
await market_sync.start_sync(session, req.days, req.force)
except ScreenerError as e:
raise HTTPException(status_code=503, detail=str(e))
status = await market_sync.get_sync_status(session)
return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields})
@router.get("/screener/sync/status", response_model=ScreenerSyncStatus)
async def screener_sync_status(session: AsyncSession = Depends(get_session)) -> ScreenerSyncStatus:
"""同步任务状态 + 数据实况(最新交易日/行数/ready"""
status = await market_sync.get_sync_status(session)
return ScreenerSyncStatus(**{k: status.get(k) for k in ScreenerSyncStatus.model_fields})
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
async def screener_preview(
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
zx: str = "10,20,30,60", end: str | None = None,
session: AsyncSession = Depends(get_session),
) -> PreviewResponse:
"""个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,落后全市场最新交易日则强制刷新)+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。
end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标),
has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。"""
if adjust not in _ADJUST_MODES:
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}")
if timeframe not in ("1d", "1w", "1M", "1y"):
raise HTTPException(status_code=400, detail="timeframe 仅支持 1d/1w/1M/1y")
try:
ma_periods = sorted({int(p) for p in mas.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
except ValueError:
raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60")
if not ma_periods:
ma_periods = [5, 10, 20, 60]
try:
zx_periods = sorted({int(p) for p in zx.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
except ValueError:
raise HTTPException(status_code=400, detail="zx 格式应为逗号分隔的数字,如 10,20,30,60")
if not zx_periods:
zx_periods = [10, 20, 30, 60]
limit = max(30, min(limit, 5000))
end_dt: datetime | None = None
if end:
try:
end_dt = datetime.strptime(end.strip()[:10], "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
symbol = plain_code(ts_code)
# --- Redis 读缓存历史窗口end 翻页)只增不改,最新窗口每日由全市场同步推进;
# 键含 ver:candles 版本号同步完成后自增旧缓存全部失效TTL 兜底cache.py
# 注ma_periods 不参与缓存键 —— 前端已改为本地计算 MA后端始终返回全量 MA 集合
cache_key = cache.digest(
"preview", ts_code, timeframe, limit, adjust,
end_dt.strftime("%Y-%m-%d") if end_dt else None,
await cache.get_version("candles"),
)
cached = await cache.cache_get(f"pv:{cache_key}")
if cached is not None:
return PreviewResponse.model_validate(cached)
# --- 日线candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
# fetcher 只做「不复权」增量 upsert底座口径恒为 bfqTDX 全量 + Tushare 增量),
# 复权qfq/hfq读取时按 adj_factor 表本地换算。
# 每次只取「窗口 + 400 根预热」行MA250/MACD EMA 在 400 根内充分收敛),不拉全量:
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
global_latest = await session.scalar(
select(func.max(Candle.ts)).where(Candle.timeframe == "1d")
)
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
fetch_n = min(100000, limit * frame_mult + 400)
source = "bfq"
mode = "bfq"
if end_dt is not None:
# 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览)
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
else:
# 注意取「最新 fetch_n 根」而非最旧get_candles 是 asc+limit取最旧窗口化后首屏会停在过期日期
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
elif global_latest is not None and rows[-1].ts.date() < global_latest.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = _rows_to_bars(rows)
if not bars and end_dt is None:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# 信息卡取未聚合的日线最新 bar聚合后 ts 是周期起点,不适用于「最新交易日」)
last_daily = bars[-1] if bars else None
prev_daily = bars[-2] if len(bars) > 1 else None
# 翻页到底end 之前无数据):返回空页 + has_more=False前端停止向前翻页
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
# 只取窗口内因子qfq 归一还需全局最新因子追加到最后一行即可_adjust_bars 取 f_latest=末项)
if adjust != mode and bars:
fq = select(AdjFactor).where(AdjFactor.ts_code == ts_code)
window_end = end_dt if end_dt is not None else bars[-1].ts
if window_end is not None:
fq = fq.where(AdjFactor.trade_date <= window_end)
factors = list((await session.execute(fq.order_by(AdjFactor.trade_date))).scalars().all())
if factors:
latest_f = (
await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)
).scalars().first()
if latest_f is not None:
factors.append(latest_f)
bars = _adjust_bars(bars, factors, mode, adjust)
mode = adjust
source = adjust
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
bars = resample_bars(bars, timeframe)
# --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过) ---
has_more = len(bars) > limit # 返回窗口之前还有更早历史(含预热行)
indicators: dict[str, dict[str, list[float | None]]] = {}
if bars:
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
closes, highs, lows = df["close"], df["high"], df["low"]
macd = ind.macd(closes)
kdj = ind.kdj(highs, lows, closes)
boll = ind.bollinger(closes)
indicators = {
# MA 始终返回全量集合(前端本地计算 MA此处仅保留兼容缓存键不依赖 ma_periods
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in _FULL_MA_SET},
"macd": {
"dif": _series_to_jsonable(macd["macd"]),
"dea": _series_to_jsonable(macd["signal"]),
"hist": _series_to_jsonable(macd["hist"]),
},
"kdj": {k: _series_to_jsonable(kdj[k]) for k in ("k", "d", "j")},
"rsi": {
"rsi6": _series_to_jsonable(ind.rsi(closes, 6)),
"rsi12": _series_to_jsonable(ind.rsi(closes, 12)),
"rsi24": _series_to_jsonable(ind.rsi(closes, 24)),
},
"boll": {k: _series_to_jsonable(boll[k]) for k in ("upper", "mid", "lower")},
"zx": {
"short": _series_to_jsonable(ind.ema2(closes)),
"duokong": _series_to_jsonable(ind.avg_ma(closes, tuple(zx_periods))),
},
}
limit = max(30, min(limit, len(bars)))
for group in indicators.values():
for key in group:
group[key] = group[key][-limit:]
# --- 信息卡stock_basic + candles 最新日线 bar + 与其对齐的快照(避免混用不同交易日) ---
sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first()
ds = None
if last_daily is not None:
# 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见)
ds = (
await session.execute(
select(DailySnapshot).where(
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == last_daily.ts
)
)
).scalars().first()
if ds is None:
ds = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.ts_code == ts_code).order_by(DailySnapshot.trade_date.desc()).limit(1)
)
).scalars().first()
def _yi(v) -> float | None:
if v is None:
return None
v = float(v)
return None if v != v else round(v / 1e4, 2) # 万元 -> 亿元
info = PreviewInfoOut(
ts_code=ts_code,
symbol=symbol,
name=sb.name if sb else ts_code,
industry=sb.industry if sb else None,
area=sb.area if sb else None,
market=sb.market if sb else None,
list_date=sb.list_date if sb else None,
trade_date=last_daily.ts if last_daily else None,
open=last_daily.open if last_daily else None,
high=last_daily.high if last_daily else None,
low=last_daily.low if last_daily else None,
close=last_daily.close if last_daily else None,
pre_close=prev_daily.close if prev_daily else None,
pct_chg=((last_daily.close / prev_daily.close - 1) * 100)
if last_daily and prev_daily and prev_daily.close else None,
volume_hand=round(last_daily.volume / 100, 0) if last_daily else None, # 股 -> 手
amount_yi=round(last_daily.amount / 1e8, 2) if last_daily and last_daily.amount else None, # 元 -> 亿元
turnover_rate=ds.turnover_rate if ds else None,
pe_ttm=ds.pe_ttm if ds else None,
pb=ds.pb if ds else None,
total_mv=_yi(ds.total_mv) if ds else None,
circ_mv=_yi(ds.circ_mv) if ds else None,
)
candles = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=b.turnover)
for b in bars[-limit:]
]
resp = PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info,
candles=candles, indicators=indicators, has_more=has_more)
await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600)
return resp