From d656c05b3d3d24cbdae5c25b6bb5fe1ce2550559 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Wed, 9 Sep 2026 11:35:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 70 +- backend/.env | 16 - backend/app/api.py | 1488 ----------------- backend/app/auth_api.py | 33 + backend/app/backtest/events.py | 114 +- backend/app/cache.py | 33 +- backend/app/config.py | 4 + backend/app/data/company.py | 97 +- backend/app/data/etf_sync.py | 39 +- backend/app/data/fetcher.py | 3 + backend/app/data/index_global.py | 54 +- backend/app/data/market_overview.py | 34 +- backend/app/data/reference.py | 9 +- backend/app/data/sync_utils.py | 5 + backend/app/main.py | 9 +- backend/app/models.py | 29 +- backend/app/schemas.py | 87 + backend/app/screener/market_sync.py | 96 +- backend/pyproject.toml | 6 + backend/scripts/test_trades_parser.py | 113 -- backend/uv.lock | 60 + backend_run.log | 862 ---------- frontend/src/api/client.ts | 55 +- frontend/src/api/types.ts | 153 +- frontend/src/components/DetailKLine.vue | 6 +- frontend/src/components/IndexKLine.vue | 133 -- .../src/components/StockDetailOverlay.vue | 8 +- frontend/src/router.ts | 4 +- frontend/src/stores/settings.ts | 10 +- frontend/src/views/EtfsView.vue | 75 +- frontend/src/views/HomeView.vue | 33 +- frontend/src/views/IndexDetailView.vue | 19 +- frontend/src/views/StocksView.vue | 83 +- frontend_dev.log | 62 - 35 files changed, 711 insertions(+), 3194 deletions(-) delete mode 100644 backend/.env delete mode 100644 backend/app/api.py delete mode 100644 backend/scripts/test_trades_parser.py delete mode 100644 backend_run.log delete mode 100644 frontend/src/components/IndexKLine.vue delete mode 100644 frontend_dev.log diff --git a/.gitignore b/.gitignore index 06491ed..2aa7b91 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ Thumbs.db # ---------- Claude ---------- .claude/settings.local.json + +# ---------- Logs ---------- +*.log diff --git a/README.md b/README.md index b7dddb0..d5792f8 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,24 @@ ## 快速启动 -两个终端分别启动后端与前端: +两个终端分别启动后端与前端(命令均从仓库根 `stock/` 执行): ```bash # 终端 1 —— 后端 API(http://localhost:8000) -cd backend -env -u SSLKEYLOGFILE uv run uvicorn app.main:app --reload --port 8000 +bash backend/restart_backend.sh # 终端 2 —— 前端开发服务器(http://localhost:5173) cd frontend pnpm dev ``` -> `env -u SSLKEYLOGFILE` 是本机(Windows)必需的:用户环境变量 `SSLKEYLOGFILE` 值开头混有不可见控制符,asyncpg 建连即崩。详见下文「快速开始(开发模式)」与「常见问题」。 +> 后端统一走 `backend/restart_backend.sh`:自动杀旧进程树 + 剔除会崩 asyncpg 的 `SSLKEYLOGFILE` + 带热重载重启 + 日志写 `backend_run.log`。Windows cmd 下等价命令:`backend\restart_backend.cmd`。详见下文「快速开始(开发模式)」。 + +**首次运行前**: +1. 装依赖:后端 `cd backend && uv sync`、前端 `cd frontend && pnpm install`。 +2. 后端依赖 **PostgreSQL(16/17)+ Redis 已启动**,并配好 `backend/.env`(复制 `.env.example`)。 +3. 建库表:`cd backend && uv run alembic upgrade head`。 -首次运行前先安装依赖:后端 `uv sync`(backend 下)、前端 `pnpm install`(frontend 下)。 浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。 --- @@ -67,28 +70,36 @@ stock/ ├── TECH_STACK.md # 技术选型与架构决策(必读) ├── README.md # 本文件 ├── backend/ # FastAPI + 回测引擎 -│ ├── pyproject.toml # uv 依赖声明 +│ ├── pyproject.toml # uv 依赖声明(pytest 在 dev 组) │ ├── .env.example # 配置示例(数据库 / 费率) -│ ├── smoke_test.py # 后端全链路自检脚本 +│ ├── smoke_test.py # 后端全链路自检脚本(线上库鉴权+核心 API) +│ ├── conftest.py # pytest 根(使 tests/ 可直接 import app.*) +│ ├── tests/ # pytest 单元测试(交割单解析/复权换算/缓存/限速/事件回测纯函数) +│ ├── scripts/ # 一次性运维脚本(TDX 导入 / 复权因子回补…) │ └── app/ -│ ├── main.py # FastAPI 入口(启动建表) +│ ├── main.py # FastAPI 入口(lifespan 拉起夜间调度) │ ├── config.py # 配置(pydantic-settings) │ ├── db.py # async SQLAlchemy 引擎/会话 │ ├── domain.py # 领域契约(Bar/Signal/Fill/Position…) -│ ├── models.py # ORM(Candle / BacktestRun) +│ ├── models.py # ORM(Candle / StockBasic / 用户数据表…) │ ├── schemas.py # Pydantic DTO(= OpenAPI 契约) -│ ├── commission.py # A 股交易成本(已修正、可配置) +│ ├── auth.py # Argon2 密码 + 会话(SHA-256 摘要) +│ ├── auth_api.py # 登录/登出(含按 IP 限速) +│ ├── scheduler.py # 夜间定时任务(收盘后自动同步 + 会话清理) +│ ├── cache.py # Redis 读缓存(本地层 + 版本号失效 + 熔断冷却恢复) │ ├── indicators.py # 指标:MACD/RSI/KDJ/布林/均线(单一事实源) -│ ├── api.py # 路由:/health /candles /backtest -│ ├── data/ # DataProvider 适配器(Tushare/AKShare)+ 周期聚合 -│ └── backtest/ # engine / broker(PaperBroker) / metrics / strategies +│ ├── api/ # 路由包:stocks / etfs / market / backtest / screener / user + _deps 共享件 +│ ├── data/ # 数据管道(tushare 适配 + 同步 + 懒加载缓存) +│ ├── screener/ # 智能选股(LLM 解析 + 全市场引擎 + 夜间同步) +│ └── backtest/ # engine / events(全市场事件回测) / strategies └── frontend/ # Vue SPA ├── src/ - │ ├── main.ts # PrimeVue(Aura 深色) + Pinia + │ ├── main.ts # Vue + Pinia │ ├── api/ # 类型化客户端 + DTO 镜像 - │ ├── stores/ # Pinia 回测状态 - │ ├── components/ # KLineChart / EquityChart / MetricsPanel / BacktestForm - │ └── views/ # BacktestView + │ ├── stores/ # Pinia(鉴权/设置/同步状态) + │ ├── composables/ # 组合式工具(防抖 ref / 路由 query 同步) + │ ├── components/ # StockDetailOverlay / DetailKLine / LimitBoard / MarketOverview… + │ └── views/ # Home / Stocks / ETF / Concepts / Indexes / Screener / Backtest └── vite.config.ts # /api 代理到 :8000 ``` @@ -117,17 +128,16 @@ pip install uv ### 1) 后端 -实际启动命令(venv 解释器直启,日志重定向到仓库根 `backend_run.log`,改代码自动热重载): +实际启动命令(一键脚本,自动杀旧进程树 + 剔除 SSLKEYLOGFILE + 热重载 + 日志写仓库根 `backend_run.log`): ```bash -cd backend -uv sync # 创建 .venv 并安装依赖(仅首次) -env -u SSLKEYLOGFILE .venv/Scripts/python.exe -m uvicorn app.main:app --reload --port 8000 > ../backend_run.log 2>&1 +uv sync # 创建 .venv 并安装依赖(仅首次) +bash backend/restart_backend.sh # 从仓库根执行(Git Bash);Windows cmd 下等价:backend\restart_backend.cmd ``` 看日志:`tail -f backend_run.log`;确认起没起:`curl http://localhost:8000/api/health`。 -等价的 `uv run` 写法(不带日志重定向,直接打到当前终端): +等价的 `uv run` 写法(不杀旧进程、日志直接打到当前终端): ```bash cd backend @@ -135,13 +145,15 @@ env -u SSLKEYLOGFILE uv run uvicorn app.main:app --reload --port 8000 ``` - **`env -u SSLKEYLOGFILE`(本机 Windows 必需)**:用户环境变量 `SSLKEYLOGFILE` 的值开头混有 U+202A 不可见控制符,asyncpg 建连时执行 `ssl.keylog_filename` 直接抛 `OSError: [Errno 22]`,uvicorn 启动即崩。Git Bash 下用 `env -u` 剔除即可;根治可 `setx SSLKEYLOGFILE "C:\Users\cirry\Desktop\fhzg.log"`(重开终端后不再需要前缀)。验证:`python -c "import os; print(repr(os.environ.get('SSLKEYLOGFILE')))"`。 -- **Windows `--reload` 僵死**:watcher 偶尔改文件不重载且日志无 Reloading 行,此时只能杀进程树重启——`netstat -ano | grep :8000` 找 PID,`taskkill //PID //T //F`。 +- **Windows `--reload` 僵死**:watcher 偶尔改文件不重载且日志无 Reloading 行,此时只能杀进程树重启——一键脚本 `bash backend/restart_backend.sh`(Git Bash)或 `backend\restart_backend.cmd`(cmd):自动杀旧进程树(`--reload` 起 launcher→reloader→worker 三层进程)+ 带 reload 重启 + 日志写 `backend_run.log`。手动:`netstat -ano | grep :8000` 找 PID,`taskkill //PID //T //F`。 - 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。 - 交互式 API 文档:http://localhost:8000/docs +- **夜间自动同步**:后端启动即拉起调度(`app/scheduler.py`),每日 18:05 本地时间自动跑全市场 A 股 + ETF 同步并清理过期会话;进程启动时若已过点且当日数据未落库会补跑。关闭:`.env` 设 `NIGHTLY_SYNC_ENABLED=false`,时间改 `NIGHTLY_SYNC_HOUR`。 -**自检**(无需起服务器,验证全链路): +**测试**: ```bash -uv run --with httpx --directory backend python smoke_test.py +uv run --directory backend pytest -q # 单元测试(交割单解析/复权换算/缓存/限速等纯函数,不碰库) +uv run --with httpx --directory backend python smoke_test.py # 全链路自检(线上库鉴权+核心 API) ``` ### 2) 前端 @@ -152,7 +164,8 @@ pnpm install # 首次 pnpm dev # http://localhost:5173 ``` -前端 `/api` 请求由 Vite 代理到后端 `:8000`(见 `vite.config.ts`),无需处理跨域。 +- **必须用 pnpm,不要用 npm/yarn**:项目锁定 `pnpm-lock.yaml`,`node_modules` 是 pnpm 的硬链接结构,`npm install` 会写坏依赖,导致装包/构建崩溃。以后装包一律 `pnpm add `(不要 `npm install `)。 +- 前端 `/api` 请求由 Vite 代理到后端 `:8000`(见 `vite.config.ts`),无需处理跨域。 打开 http://localhost:5173 → 选周期、改参数 → 点「开始回测」。 鼠标悬停 K 线可看当日详情弹框;切日线/周线/月线/年线;勾「fast 模式」可对比关闭费用/T+1 的差异。 @@ -229,9 +242,10 @@ EXPOSE_API_DOCS=false | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/health` | 健康检查 | -| GET | `/api/candles/{symbol}?timeframe=1d&limit=1000` | 取 K 线(`timeframe`: `1d`/`1w`/`1M`/`1y`) | | POST | `/api/data/sync` | 拉取并缓存某标的日线(Tushare 主 → AKShare 兜底)。body: `{symbol, source?, force?}` | -| POST | `/api/backtest` | 跑回测(真实标的首次自动拉取并缓存) | +| POST | `/api/backtest` | 跑回测(真实标的首次自动拉取并缓存;前端已改用 `/api/backtest/event` 事件回测,此为旧策略回测 API) | + +个股 K 线统一走 `GET /api/screener/preview/{ts_code}`(含复权/指标预热/翻页,两级缓存)。 **回测请求示例**: ```json diff --git a/backend/.env b/backend/.env deleted file mode 100644 index 54ad15e..0000000 --- a/backend/.env +++ /dev/null @@ -1,16 +0,0 @@ -DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock -# TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390 -TUSHARE_TOKEN=2f7dbca732cdb762eb61bf3ca1b58f0c19a12732346ae06b3f60f4f5 -# 15000 积分档走 quicksync 镜像(官方接口对该 token 返回 40101) -TUSHARE_API_URL=http://api.quicksync.cn -DATA_ADJUST=qfq -DATA_DEFAULT_START=20200101 - -# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)---- -REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379 - -# ---- LLM(智能选股;DeepSeek,OpenAI 兼容协议;/anthropic 后缀会被 _endpoint 自动归一)---- -# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret) -LLM_BASE_URL=https://api.deepseek.com -LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515 -LLM_MODEL=deepseek-chat diff --git a/backend/app/api.py b/backend/app/api.py deleted file mode 100644 index de3ecf2..0000000 --- a/backend/app/api.py +++ /dev/null @@ -1,1488 +0,0 @@ -"""HTTP 路由(OpenAPI 契约的载体)。 - - GET /api/health 健康检查 - GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合) - GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数) - 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 启动全市场数据同步(后台任务) - GET /api/screener/sync/status 同步任务状态与数据实况 -""" -from __future__ import annotations - -import asyncio -import bisect -import json -from datetime import datetime - -import pandas as pd -from pydantic import TypeAdapter -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile -from fastapi.responses import StreamingResponse -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 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 is_etf_symbol, plain_code, to_ts_code -from .db import async_session, get_session -from .domain import Bar -from . import indicators as ind -from .trades import parse_statement -from .models import ( - AdjFactor, - BacktestRun, - Candle, - ScreenerQuery, - StockBasic, - TradeCalendar, - UserPreference, - UserTrade, - WatchlistItem, -) -from .schemas import ( - BacktestRequest, - BacktestResponse, - CandleOut, - EquityPoint, - EventBacktestRequest, - EventBacktestResponse, - EtfListItemOut, - EtfListResponse, - EtfSyncRequest, - EtfSyncStatus, - GlobalIndexListResponse, - IndexBasicOut, - IndexDetailResponse, - IndexQuoteBriefOut, - IndexValuationPointOut, - IndexWeightItemOut, - IndexWeightsResponse, - IndicatorOut, - MarketOverviewResponse, - MetricsOut, - PreferencesOut, - PreferencesUpdate, - PreviewInfoOut, - PreviewResponse, - ScreenerQueryListResponse, - ScreenerQueryOut, - ScreenerRunRequest, - ScreenerRunResponse, - ScreenerSyncRequest, - ScreenerSyncStatus, - SignalOut, - StockCompanyOut, - StockDividendOut, - StockFinanceOut, - StockReferenceOut, - 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 _raw_json(resp) -> str: - """pydantic-core(Rust)序列化:与 response_model 直返时的字节完全一致(紧凑分隔符、 - 非 ASCII 直出、浮点小数形式),且比 stdlib json.dumps 快。大响应(preview ~250KB) - 命中缓存时直接 Response 原样返回,跳过校验/再序列化。""" - return resp.model_dump_json() - - -async def _cached_json_response(key: str) -> Response | None: - """两级缓存读(进程内 → Redis):命中返回可直接吐给客户端的 Response。 - 存的均为序列化好的 JSON 字符串(Redis 侧 json.loads 后仍是 str),Redis 命中顺手晋级本地。""" - raw = cache.local_get(key) - if raw is None: - raw = await cache.cache_get(key) - if not isinstance(raw, str): - return None - cache.local_set(key, raw, ttl=120) - return Response(content=raw, media_type="application/json") - - -def _series_to_jsonable(s: pd.Series) -> list[float | None]: - """NaN -> None(lightweight-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) -# 指数 K 线支持的周期(日线基底聚合) -_INDEX_TIMEFRAMES = ("1d", "1w", "1M", "1y") - -# 信息卡一条 SQL 拿全:stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot -# (LATERAL 单条替换原两条查询,语义不变:target 为 NULL 时全按最新日兜底)。 -# ETF 走 etf_basic 分支(代码前缀与股票不重叠,两分支至多一个命中): -# 名称/上市日来自表内,市值(元)换算成万元与快照口径一致,无 PE/PB。 -_INFO_SQL = text( - """ - 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 - """ -) - -# 复权因子是阶梯函数(除权日之间不变):只取「变化点」行,把每符号 ~7000 行日级因子压到 -# 几十行(600118 仅 32 行),传输量再降两个数量级;lag 窗口在覆盖索引上走 Index Only Scan。 -# upto 传全局最新交易日(非分页)或 end 日期(分页);窗口首行 prev 为 NULL 恒被保留(窗口基线因子)。 -_FACTOR_STEP_SQL = text( - """ - SELECT trade_date, adj_factor FROM ( - SELECT trade_date, adj_factor, - lag(adj_factor) OVER (ORDER BY trade_date) AS prev - FROM adj_factor - WHERE ts_code = :code AND trade_date <= :upto - ) t - WHERE adj_factor IS DISTINCT FROM prev - ORDER BY trade_date - """ -) - - -def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]: - """按复权因子把 K 线从 from_mode 换算到 to_mode(bfq/qfq/hfq)。 - - 相对不复权的乘数:bfq=1,qfq=f(t)/f(latest),hfq=f(t)。 - 因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。 - """ - fd = sorted((f[0].date(), float(f[1])) 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), -) -> Response: - """全市场股票列表: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)恒排末尾。 - 缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total);自选增删即时失效; - 与 preview 同款序列化 JSON 直返(j 前缀),命中跳过 pydantic 校验/序列化。""" - 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"stocksj: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 _cached_json_response(key) - if cached is not None: - return 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]) - 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.get("/stocks/facets", response_model=StockFacetsResponse) -async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response: - """看股页筛选项:行业 / 地域(含数量,按数量降序)。stock_basic 很少变,长缓存。""" - cached = await _cached_json_response("facetsj:stocks") - if cached is not None: - return 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], - ) - raw = _raw_json(resp) - cache.local_set("facetsj:stocks", raw, ttl=min(120, settings.facets_cache_ttl)) - asyncio.create_task(cache.cache_set("facetsj:stocks", raw, ttl=settings.facets_cache_ttl)) - 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), - 沪深两市市值/成交统计 + 成交额历史。部分来源失败不影响其余。 - """ - # 今日是否 A 股交易日(trade_date 为 String(8) unique 索引,等值查亚毫秒级); - # DB 不可用时传 None,fetch_overview 回退 weekday 启发式 - today8 = datetime.now().strftime("%Y%m%d") - is_trading_day: bool | None = None - try: - is_trading_day = bool(await session.scalar( - select(TradeCalendar.id).where(TradeCalendar.trade_date == today8).limit(1) - )) - except Exception: # noqa: BLE001 —— 判定失败只影响「今日盘中 bar」是否追加 - pass - try: - data = await fetch_overview(is_trading_day=is_trading_day) - except MarketOverviewError as e: - raise HTTPException(status_code=503, detail=str(e)) from e - return MarketOverviewResponse(**data) - - -@router.get("/market/index-candles", response_model=list[CandleOut]) -async def get_index_candles(timeframe: str = "1d") -> Response: - """上证指数全量 K 线:日线为基底(tushare index_daily,进程内+Redis 缓存), - 聚合到 1d/1w/1M/1y。收盘口径(数据随 EOD 更新,与总览 spark 一致)。""" - if timeframe not in _INDEX_TIMEFRAMES: - raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(_INDEX_TIMEFRAMES)}") - key = f"idxkj:{cache.digest('idxc', SH_INDEX, timeframe)}" - cached = await _cached_json_response(key) - if cached is not None: - return cached - try: - bars = resample_bars(await get_index_daily(), timeframe) - except Exception as e: # noqa: BLE001 - raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}") - outs = [ - CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, - volume=b.volume, amount=b.amount, turnover=None) - for b in bars - ] - # pydantic-core 序列化(与 _raw_json 同款),历史不可变、TTL 兜到当日更新 - raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode() - cache.local_set(key, raw, ttl=300) - await cache.cache_set(key, raw, ttl=7200) - return Response(content=raw, media_type="application/json") - - -@router.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, - 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), - is_fund=is_etf_symbol(req.symbol), # ETF 免印花税/过户费 - ) - 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") -async def screener_run( - req: ScreenerRunRequest, - session: AsyncSession = Depends(get_session), - user=Depends(require_user), -) -> StreamingResponse: - """自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。 - - NDJSON 流式响应(每行一个 JSON 事件,前端逐行渲染进度): - {"type":"stage","key":"llm|date|prefilter|bars|filter_done|done","msg":"…","ms":123} - {"type":"parsed","conditions":{…},"ms":456} LLM 解析出的结构化条件 - {"type":"candidates","count":5400,"msg":"…","ms":…} SQL 预筛后的候选数 - {"type":"progress","done":500,"total":5400} 逐股指标过滤进度 - {"type":"result","result":{…ScreenerRunResponse…},"ms":…} - {"type":"error","message":"…","code":400} 流中途失败(HTTP 已 200) - 成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。 - """ - limit = settings.screener_default_limit - - async def gen(): - try: - if req.conditions: - conds = req.conditions - else: - yield _ndjson({"type": "stage", "key": "llm", - "msg": f"AI 解析条件中({settings.llm_model})…"}) - conds = await parse_conditions(req.text) - if not conds.indicator and not conds.snapshot: - yield _ndjson({"type": "error", "code": 400, - "message": "AI 未从描述中解析出任何筛选条件,请换种说法"}) - return - yield _ndjson({"type": "parsed", "conditions": conds.model_dump()}) - - result = None - async for ev in engine.run_screen_events(session, conds, limit): - if ev["type"] == "result": - result = ev["result"] - yield _ndjson({"type": "stage", "key": "done", "ms": ev.get("ms"), - "msg": f"筛选完成:{result['total']} 只命中(数据基准 {result['trade_date']:%Y-%m-%d})"}) - else: - yield _ndjson(ev) - - if result is None: - yield _ndjson({"type": "error", "code": 500, "message": "选股流程未产出结果"}) - return - yield _ndjson({"type": "result", "result": ScreenerRunResponse(**result).model_dump(mode="json")}) - - # 相同文本 + 相同条件的上一条不重复记录(一键重跑场景) - 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() - except DataNotReadyError as e: - yield _ndjson({"type": "error", "code": 409, "message": str(e)}) - except ValueError as e: # 未知指标/字段、条件为空 - yield _ndjson({"type": "error", "code": 400, "message": str(e)}) - except ScreenerError as e: - code = 503 if "未配置 LLM_API_KEY" in str(e) else 502 - yield _ndjson({"type": "error", "code": code, "message": str(e)}) - except Exception as e: # noqa: BLE001 - yield _ndjson({"type": "error", "code": 500, "message": f"选股失败: {e}"}) - - return StreamingResponse(gen(), media_type="application/x-ndjson", - headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}) - - -def _ndjson(obj: dict) -> str: - """dict -> NDJSON 行(json.dumps 保证 default=str 兜底 datetime 等)。""" - return json.dumps(obj, ensure_ascii=False, default=str) + "\n" - - -@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), -) -> Response: - """个股详情预览:日线(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) - - # --- 两级读缓存:历史窗口(end 翻页)只增不改,最新窗口每日由全市场同步推进; - # 键含 ver:candles 版本号(同步完成后自增,旧缓存全部失效),TTL 兜底(cache.py)。 - # 存序列化好的 JSON 直返(j: 前缀),跳过 json.loads + pydantic 校验/序列化(热路径数百 ms → 个位数)。 - # 注: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 _cached_json_response(f"pvj:{cache_key}") - if cached is not None: - return cached - - # --- 日线:candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 --- - # fetcher 只做「不复权」增量 upsert,底座口径恒为 bfq(TDX 全量 + Tushare 增量), - # 复权(qfq/hfq)读取时按 adj_factor 表本地换算。 - # 每次只取「窗口 + 400 根预热」行(MA250/MACD EMA 在 400 根内充分收敛),不拉全量: - # 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。 - frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe] - fetch_n = min(100000, limit * frame_mult + 400) - source = "bfq" - mode = "bfq" - # 并发约定:注入 session 与 s2 各占一条连接,每次 gather 里每个 session 恰好跑一条查询 - # (AsyncSession 单连接非并发安全),把 ~6 次串行 DB RTT 折叠成 2 个波次。 - async with async_session() as s2: - if end_dt is not None: - # 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览);max(ts) 用不到 - rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n) - global_latest = None - else: - # Wave 1:candles 窗口(注入 session)+ 全市场最新交易日(s2)并行 - rows, global_latest = await asyncio.gather( - repository.get_recent_candles(session, symbol, "1d", limit=fetch_n), - s2.scalar(select(func.max(Candle.ts)).where(Candle.timeframe == "1d")), - ) - 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,前端停止向前翻页 - - # --- Wave 2:复权因子(s2,覆盖索引 Index Only Scan)+ 信息卡(注入 session,LATERAL 一条)并行 --- - async def _fetch_factors() -> list | None: - if adjust == mode or not bars: - return None - # 只取因子「变化点」行(覆盖索引 Index Only Scan,免堆访问——adj_factor 堆碎片化 - # 严重);bisect 在阶梯函数上取值与日级序列逐字节一致 - if end_dt is not None: - # 分页:窗口 ≤ end 的变化点 + 全局最新因子(qfq 以最新因子归一) - win = list((await s2.execute(_FACTOR_STEP_SQL, {"code": ts_code, "upto": end_dt})).all()) - if win: - latest_f = (await s2.execute( - select(AdjFactor.trade_date, AdjFactor.adj_factor) - .where(AdjFactor.ts_code == ts_code) - .order_by(AdjFactor.trade_date.desc()).limit(1) - )).first() - if latest_f is not None: - win.append(latest_f) - return win or None - # 非分页:上界 global_latest(≥ 最新 bar),末项变化点即全局最新因子,比 - # 「窗口 ≤ bars[-1].ts + 单独 latest」少一次查询 - return list((await s2.execute( - _FACTOR_STEP_SQL, {"code": ts_code, "upto": global_latest} - )).all()) or None - - factors, info_row = await asyncio.gather( - _fetch_factors(), - session.execute(_INFO_SQL, {"code": ts_code, "target": last_daily.ts if last_daily else None}), - ) - - # --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) --- - if factors: - 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:] - - # --- 信息卡:Wave 2 已并行取回(stock_basic + 与行情同日对齐的快照、缺则最新日,见 _INFO_SQL) --- - row = info_row.first() - if row is not None: - m = row._mapping - sb_name, sb_industry, sb_area, sb_market, sb_list_date = ( - m["name"], m["industry"], m["area"], m["market"], m["list_date"] - ) - ds_turnover, ds_pe, ds_pb, ds_tmv, ds_cmv = ( - m["turnover_rate"], m["pe_ttm"], m["pb"], m["total_mv"], m["circ_mv"] - ) - else: - sb_name = sb_industry = sb_area = sb_market = sb_list_date = None - ds_turnover = ds_pe = ds_pb = ds_tmv = ds_cmv = None - - 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 or ts_code, - industry=sb_industry, - area=sb_area, - market=sb_market, - list_date=sb_list_date, - 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, - pe_ttm=ds_pe, - pb=ds_pb, - total_mv=_yi(ds_tmv), - circ_mv=_yi(ds_cmv), - ) - - 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) - # 只序列化一次:本地(同步,120s)+ Redis(后台写,600s TTL 兜底跨进程/重启) - raw = _raw_json(resp) - cache.local_set(f"pvj:{cache_key}", raw, ttl=120) - asyncio.create_task(cache.cache_set(f"pvj:{cache_key}", raw, ttl=600)) - return Response(content=raw, media_type="application/json") diff --git a/backend/app/auth_api.py b/backend/app/auth_api.py index c3d63e3..65a81ad 100644 --- a/backend/app/auth_api.py +++ b/backend/app/auth_api.py @@ -1,6 +1,8 @@ """只登录、不注册的鉴权 API。""" from __future__ import annotations +import time +from collections import deque from datetime import timedelta from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status @@ -49,6 +51,33 @@ def clear_session_cookie(response: Response) -> None: ) +# ---------- 登录按 IP 限速(进程内滑动窗口,无外部依赖) ---------- +# 补充账户级锁定(auth_max_failed_logins):本层拦多 IP 分布爆破,也稀释 +# 「故意输错 5 次锁死他人账户」的滥用面。反代部署时 host 是代理 IP,需改读 X-Forwarded-For。 +_LOGIN_WINDOW = 60.0 +_LOGIN_MAX_PER_WINDOW = 15 +_login_attempts: dict[str, deque[float]] = {} +_login_gc_at = 0.0 + + +def _login_rate_limited(ip: str) -> bool: + """超限返回 True;未超限记录本次尝试(成功失败都计)。""" + global _login_gc_at + now = time.monotonic() + q = _login_attempts.setdefault(ip, deque()) + while q and q[0] <= now - _LOGIN_WINDOW: + q.popleft() + if len(q) >= _LOGIN_MAX_PER_WINDOW: + return True + q.append(now) + if now - _login_gc_at > 3600: # 顺手回收陈旧 entry,防长跑内存增长 + _login_gc_at = now + stale = now - _LOGIN_WINDOW * 10 + for k in [k for k, v in _login_attempts.items() if not v or v[-1] <= stale]: + del _login_attempts[k] + return False + + @router.post("/login", response_model=LoginResponse) async def login( payload: LoginRequest, @@ -56,6 +85,10 @@ async def login( response: Response, db: AsyncSession = Depends(get_session), ) -> LoginResponse: + ip = request.client.host if request.client else "?" + if _login_rate_limited(ip): + raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试") + now = utcnow() username = payload.username.strip() user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none() diff --git a/backend/app/backtest/events.py b/backend/app/backtest/events.py index 03073fa..f230bd8 100644 --- a/backend/app/backtest/events.py +++ b/backend/app/backtest/events.py @@ -11,6 +11,7 @@ match=all(连续满足)/any(曾经满足),多条件之间取 AND。 """ from __future__ import annotations +import asyncio from datetime import date, datetime, timedelta import numpy as np @@ -120,6 +121,69 @@ def _stats_block(trades: list[dict]) -> dict: } +def _scan_batch( + candle_rows: list, + code_by_symbol: dict[str, str], + f_map: dict, + name_map: dict[str, str], + spec: EventBacktestSpec, + start_ts: datetime, + trades_limit: int, +) -> list[dict]: + """纯 CPU:一批 bar -> 交易明细(放线程池跑,不阻塞事件循环)。""" + trades: list[dict] = [] + if not candle_rows: + return trades + bars = pd.DataFrame( + candle_rows, columns=["symbol", "ts", "open", "high", "low", "close"] + ) + for symbol, g in bars.groupby("symbol", sort=False): + if len(g) < 30: + continue + g = g.reset_index(drop=True) + ts_code_l = code_by_symbol[symbol] + cache: dict = {"_families": set()} + mask = _signal_mask(g, spec, cache) + if not mask.any(): + continue + for sig_i in np.flatnonzero(mask.to_numpy()): + ts_sig = g.at[sig_i, "ts"] + # 信号必须落在回测窗口内(buffer 区只用于指标配热) + if ts_sig < start_ts: + continue + ie = _entry_exit_indices(int(sig_i), spec, len(g)) + if ie is None: + continue + entry_i, exit_i = ie + e_row, x_row = g.iloc[entry_i], g.iloc[exit_i] + e_price = _price_at(e_row, "open" if spec.entry_timing == "next_open" else "close") + x_price = _price_at(x_row, "open" if spec.exit_timing == "open" else "close") + if not e_price or not x_price: + continue + f_in = f_map.get((ts_code_l, e_row["ts"].date()), 1.0) + f_out = f_map.get((ts_code_l, x_row["ts"].date()), 1.0) + ret_pct = (x_price * f_out) / (e_price * f_in) * 100 - 100 + trades.append({ + "ts_code": ts_code_l, + "name": name_map.get(ts_code_l), + "entry_date": e_row["ts"], "entry_price": round(e_price, 3), + "exit_date": x_row["ts"], "exit_price": round(x_price, 3), + "ret_pct": round(float(ret_pct), 3), + }) + if len(trades) >= trades_limit: + return trades + return trades + + +def _summarize(trades: list[dict]) -> tuple[dict, list[dict]]: + """纯 CPU:汇总统计 + 最好/最差样本(同样下线程池)。""" + stats = _stats_block(trades) + # 明细样本:最好 100 + 最差 100(其余统计已覆盖) + trades_sorted = sorted(trades, key=lambda t: t["ret_pct"], reverse=True) + sample = trades_sorted[:100] + (trades_sorted[-100:] if len(trades_sorted) > 100 else []) + return stats, sample + + async def run_event_backtest( session: AsyncSession, spec: EventBacktestSpec, @@ -195,53 +259,15 @@ async def run_event_backtest( )).all() f_map = {(r[0], r[1].date()): float(r[2]) for r in adj_rows if r[2]} - bars = pd.DataFrame( - candle_rows, columns=["symbol", "ts", "open", "high", "low", "close"] - ) - for symbol, g in bars.groupby("symbol", sort=False): - if len(g) < 30: - continue - g = g.reset_index(drop=True) - ts_code_l = code_by_symbol[symbol] - cache: dict = {"_families": set()} - mask = _signal_mask(g, spec, cache) - if not mask.any(): - continue - for sig_i in np.flatnonzero(mask.to_numpy()): - ts_sig = g.at[sig_i, "ts"] - # 信号必须落在回测窗口内(buffer 区只用于指标配热) - if ts_sig < start_ts: - continue - ie = _entry_exit_indices(int(sig_i), spec, len(g)) - if ie is None: - continue - entry_i, exit_i = ie - e_row, x_row = g.iloc[entry_i], g.iloc[exit_i] - e_price = _price_at(e_row, "open" if spec.entry_timing == "next_open" else "close") - x_price = _price_at(x_row, "open" if spec.exit_timing == "open" else "close") - if not e_price or not x_price: - continue - f_in = f_map.get((ts_code_l, e_row["ts"].date()), 1.0) - f_out = f_map.get((ts_code_l, x_row["ts"].date()), 1.0) - ret_pct = (x_price * f_out) / (e_price * f_in) * 100 - 100 - trades.append({ - "ts_code": ts_code_l, - "name": name_map.get(ts_code_l), - "entry_date": e_row["ts"], "entry_price": round(e_price, 3), - "exit_date": x_row["ts"], "exit_price": round(x_price, 3), - "ret_pct": round(float(ret_pct), 3), - }) - if len(trades) >= MAX_TRADES: - break - if len(trades) >= MAX_TRADES: - break + # pandas 全市场扫描是同步 CPU 重计算,丢线程池跑(await 期间事件循环可服务其他请求) + trades.extend(await asyncio.to_thread( + _scan_batch, candle_rows, code_by_symbol, f_map, name_map, + spec, start_ts, MAX_TRADES - len(trades), + )) if len(trades) >= MAX_TRADES: break - stats = _stats_block(trades) - # 明细样本:最好 100 + 最差 100(其余统计已覆盖) - trades_sorted = sorted(trades, key=lambda t: t["ret_pct"], reverse=True) - sample = trades_sorted[:100] + (trades_sorted[-100:] if len(trades_sorted) > 100 else []) + stats, sample = await asyncio.to_thread(_summarize, trades) return { "spec": spec, "universe": ts_code or "all", diff --git a/backend/app/cache.py b/backend/app/cache.py index f65145c..45df1f2 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -1,7 +1,8 @@ """Redis 读缓存(可选基础设施)。 -- REDIS_URL 留空、连接失败或超时:所有操作静默退化为「无缓存」,接口照常直查数据库, - 且本进程内禁用重试(避免每个请求都陪跑一次连接超时)。 +- REDIS_URL 留空、连接失败或超时:所有操作静默退化为「无缓存」,接口照常直查数据库。 + 故障后熔断 _RECOVERY_SECONDS(期间所有请求直连,不陪跑连接超时),到期自动放行 + 一次探测——成功即完全恢复,仍失败则重新熔断(Redis 属加速件,坏了不能拖慢接口)。 - 失效策略:TTL 自然过期 + 版本号(INCR)作废。自选股增删等写操作只 INCR 版本 key, 旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。 - 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等); @@ -13,6 +14,7 @@ """ from __future__ import annotations +import asyncio import hashlib import json import time @@ -23,12 +25,13 @@ import redis.asyncio as aioredis from .config import settings _pool: aioredis.ConnectionPool | None = None -_disabled = False # 一次失败后本进程禁用(Redis 属加速件,坏了不能拖慢接口) +_RECOVERY_SECONDS = 60.0 # 熔断时长:期间直连不试 Redis,到期放行一次探测 +_disabled_until = 0.0 # 熔断截止的 monotonic 时刻;0 = 未熔断 def _client() -> aioredis.Redis | None: - global _pool, _disabled - if not settings.redis_url or _disabled: + global _pool + if not settings.redis_url or time.monotonic() < _disabled_until: return None if _pool is None: _pool = aioredis.ConnectionPool.from_url( @@ -43,8 +46,8 @@ def _client() -> aioredis.Redis | None: def _bail() -> None: - global _disabled - _disabled = True + global _disabled_until + _disabled_until = time.monotonic() + _RECOVERY_SECONDS def digest(*parts: Any) -> str: @@ -84,7 +87,21 @@ def local_set(key: str, raw: str, ttl: int) -> None: _local_store[key] = (time.monotonic() + max(1, min(ttl, 120)), raw) _local_bytes += len(raw) + 64 # 连同 dict/tuple 开销粗略计入 while _local_store and (len(_local_store) > _LOCAL_MAX_ENTRIES or _local_bytes > _LOCAL_MAX_BYTES): - _local_bytes -= len(_local_store.popitem(last=False)[1][1]) + 64 + # dict 是插入序:next(iter(...)) 即最旧键(dict.popitem 不支持参数,别写成 OrderedDict 的写法) + oldest = next(iter(_local_store)) + _local_bytes -= len(_local_store.pop(oldest)[1]) + 64 + + +# --- 后台写(fire-and-forget)------------------------------------------------ +# 读路径拿到响应后异步写 Redis、不阻塞返回。统一入口:任务挂全局集合防 GC, +# cache_set 内部自带异常静默(缓存层尽力而为),优雅停机丢最后一次写无害(TTL 兜底)。 +_bg_tasks: set[asyncio.Task] = set() + + +def set_bg(key: str, value: Any, ttl: int) -> None: + t = asyncio.create_task(cache_set(key, value, ttl)) + _bg_tasks.add(t) + t.add_done_callback(_bg_tasks.discard) # --- 版本号本地缓存:热请求连 Redis GET ver:xx 都省掉 ------------------------ diff --git a/backend/app/config.py b/backend/app/config.py index 0b8b819..33ec9a3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -56,6 +56,10 @@ class Settings(BaseSettings): screener_default_limit: int = 200 # 选股结果条数上限 screener_sync_interval: float = 0.35 # 全市场批量调用间隔(秒),Tushare 控频 + # ---- 夜间定时任务(收盘后自动同步 + 会话清理;见 app/scheduler.py)---- + nightly_sync_enabled: bool = True + nightly_sync_hour: int = 18 # 本地时间整点,触发在 :05(收盘后日线已生成) + # A股交易成本(基准日 2026-08)——做成可配置参数,便于将来按生效日期版本化 stamp_duty_rate: float = 0.0005 # 印花税 0.05%,单边卖出(2023-08-28 减半) transfer_fee_rate: float = 0.00001 # 过户费 0.001%,沪深双边(2022 调整) diff --git a/backend/app/data/company.py b/backend/app/data/company.py index e9436fa..dde03ac 100644 --- a/backend/app/data/company.py +++ b/backend/app/data/company.py @@ -9,7 +9,7 @@ from __future__ import annotations import asyncio import time -from datetime import datetime, timedelta, timezone +from datetime import datetime from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -18,12 +18,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..db import async_session from ..models import StockCompany +from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, s_clean, utcnow _REFRESH_DAYS = 30 -# 频率超限特征(等待 62s 重试一次;与 screener.market_sync / data.etf_sync._call_retry 同款语义) -_RATE_MARKS = ("频率超限", "每分钟") - # 显式列出全部字段:introduction/office/main_business/business_scope 文档标注默认不显示, # 不传 fields 时 tushare 不返回这四列(实测 000001.SZ) _FIELDS = ( @@ -32,87 +30,38 @@ _FIELDS = ( "employees,main_business,business_scope" ) -_pro = None # 惰性单例(get_pro 每次都 ts.set_token 写文件,没必要重复) - - -def _get_pro(): - if not settings.tushare_token: - raise RuntimeError("未配置 TUSHARE_TOKEN,无法拉取公司简介(backend/.env)") - global _pro - if _pro is None: - from .tushare_provider import get_pro - - _pro = get_pro() - return _pro - - -def _call_retry(fn, *args, **kwargs): - """同步调用 tushare 接口;「每分钟」级频率超限等 62s 重试一次。""" - try: - return fn(*args, **kwargs) - except Exception as e: # noqa: BLE001 - msg = str(e) - if any(m in msg for m in _RATE_MARKS) and "小时" not in msg: - time.sleep(62) - return fn(*args, **kwargs) - raise - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc).replace(tzinfo=None) - - -def _fresh(updated_at: datetime | None) -> bool: - return updated_at is not None and updated_at >= _utcnow() - timedelta(days=_REFRESH_DAYS) - - -def _s(v) -> str | None: - """pandas NaN / 空串 / None -> None,其余 strip。""" - if v is None or (isinstance(v, float) and v != v): - return None - s = str(v).strip() - return s or None - - -def _f(v) -> float | None: - try: - f = float(v) - except (TypeError, ValueError): - return None - return None if f != f else f # NaN -> None - def _i(v) -> int | None: - f = _f(v) + f = f_clean(v) return None if f is None else int(f) def fetch_company_sync(ts_code: str) -> dict | None: """同步拉单只公司简介(需在 to_thread 里跑);返回行 dict,无此股返回 None。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(_get_pro().stock_company, ts_code=ts_code, fields=_FIELDS) + df = call_retry(get_pro_lazy().stock_company, ts_code=ts_code, fields=_FIELDS) if df is None or df.empty: return None r = df.iloc[0] return { "ts_code": ts_code, - "com_name": _s(r.get("com_name")), - "com_id": _s(r.get("com_id")), - "chairman": _s(r.get("chairman")), - "manager": _s(r.get("manager")), - "secretary": _s(r.get("secretary")), - "reg_capital": _f(r.get("reg_capital")), - "setup_date": _s(r.get("setup_date")), - "province": _s(r.get("province")), - "city": _s(r.get("city")), - "introduction": _s(r.get("introduction")), - "website": _s(r.get("website")), - "email": _s(r.get("email")), - "office": _s(r.get("office")), + "com_name": s_clean(r.get("com_name")), + "com_id": s_clean(r.get("com_id")), + "chairman": s_clean(r.get("chairman")), + "manager": s_clean(r.get("manager")), + "secretary": s_clean(r.get("secretary")), + "reg_capital": f_clean(r.get("reg_capital")), + "setup_date": s_clean(r.get("setup_date")), + "province": s_clean(r.get("province")), + "city": s_clean(r.get("city")), + "introduction": s_clean(r.get("introduction")), + "website": s_clean(r.get("website")), + "email": s_clean(r.get("email")), + "office": s_clean(r.get("office")), "employees": _i(r.get("employees")), - "main_business": _s(r.get("main_business")), - "business_scope": _s(r.get("business_scope")), - "updated_at": _utcnow(), + "main_business": s_clean(r.get("main_business")), + "business_scope": s_clean(r.get("business_scope")), + "updated_at": utcnow(), } @@ -151,7 +100,7 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None: """ row = (await session.execute( select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() - if row is not None and _fresh(row.updated_at): + if row is not None and fresh(row.updated_at, _REFRESH_DAYS): return _row_dict(row) if row.com_name is not None else None # 墓碑 -> None # 释放请求会话持有的连接:后面可能隔着 1-2s 的 tushare 调用,别长占连接池。 # 用 close() 而非 rollback():rollback 会把会话身份映射里的实例全部 expire—— @@ -165,7 +114,7 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None: async with async_session() as s2: # 锁内重读 + 写入走新会话 row = (await s2.execute( select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() - if row is not None and _fresh(row.updated_at): + if row is not None and fresh(row.updated_at, _REFRESH_DAYS): return _row_dict(row) if row.com_name is not None else None try: fetched = await asyncio.to_thread(fetch_company_sync, ts_code) @@ -174,5 +123,5 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None: if row is not None and row.com_name is not None: return _row_dict(row) raise - await _upsert(s2, fetched or {"ts_code": ts_code, "updated_at": _utcnow()}) + await _upsert(s2, fetched or {"ts_code": ts_code, "updated_at": utcnow()}) return fetched diff --git a/backend/app/data/etf_sync.py b/backend/app/data/etf_sync.py index 1cd4327..a3a2928 100644 --- a/backend/app/data/etf_sync.py +++ b/backend/app/data/etf_sync.py @@ -20,7 +20,7 @@ from __future__ import annotations import asyncio import time -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from sqlalchemy import delete, func, select, text from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -29,6 +29,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from .. import cache from ..config import settings from . import etf_provider +from .sync_utils import call_retry, get_pro_lazy, utcnow # 进程内单例任务状态(uvicorn 单进程场景够用) _state: dict = { @@ -46,34 +47,6 @@ _lock = asyncio.Lock() _BATCH = 3000 # upsert 分批行数(asyncpg 单语句参数上限 32766,10 列/行) # fund_daily 返回全市场基金 ~2100 行,一天一批远小于上限 -# 频率超限特征(等待 62s 重试一次;与 screener.market_sync._call_retry 同款语义) -_RATE_MARKS = ("频率超限", "每分钟") - - -def _call_retry(fn, *args, **kwargs): - """同步调用 tushare 接口;「每分钟」级频率超限等 62s 重试一次。""" - try: - return fn(*args, **kwargs) - except Exception as e: # noqa: BLE001 - msg = str(e) - if any(m in msg for m in _RATE_MARKS) and "小时" not in msg: - time.sleep(62) - return fn(*args, **kwargs) - raise - - -def _get_pro(): - """token 检查 + 返回 pro api 客户端(同步对象,调用需 to_thread 包裹)。""" - if not settings.tushare_token: - raise RuntimeError("未配置 TUSHARE_TOKEN,无法同步 ETF 日线(backend/.env)") - from .tushare_provider import get_pro - - return get_pro() - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc).replace(tzinfo=None) - def _parse_d(s: str) -> datetime: return datetime.strptime(str(s), "%Y%m%d") @@ -85,7 +58,7 @@ async def _sync_spot(session: AsyncSession) -> int: async with etf_provider.new_client() as client: rows = await etf_provider.fetch_etf_spot(client) - now = _utcnow() + now = utcnow() stmt = pg_insert(EtfBasic).values([{**r, "updated_at": now} for r in rows]) stmt = stmt.on_conflict_do_update( index_elements=["ts_code"], @@ -108,7 +81,7 @@ async def _sync_spot(session: AsyncSession) -> int: def _fetch_day_sync(pro, d: str) -> list[dict]: """拉某交易日全市场场内基金日线(fund_daily;未生成的日期返回空)。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(pro.fund_daily, trade_date=d) + df = call_retry(pro.fund_daily, trade_date=d) if df is None or df.empty: return [] rows = [] @@ -128,7 +101,7 @@ def _fetch_day_sync(pro, d: str) -> list[dict]: def _fetch_symbol_sync(pro, ts_code: str, start: str | None, end: str | None) -> list[dict]: """按 ts_code 增量/全量拉单只 ETF 日线(start=None 即上市以来全量)。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(pro.fund_daily, ts_code=ts_code, start_date=start, end_date=end) + df = call_retry(pro.fund_daily, ts_code=ts_code, start_date=start, end_date=end) if df is None or df.empty: return [] df = df.sort_values("trade_date") @@ -209,7 +182,7 @@ async def _run_sync(full: bool) -> None: from ..models import Candle, EtfBasic, TradeCalendar try: - pro = await asyncio.to_thread(_get_pro) + pro = await asyncio.to_thread(get_pro_lazy) # 1) 快照 -> etf_basic _state["step"] = "正在拉取 ETF 列表" diff --git a/backend/app/data/fetcher.py b/backend/app/data/fetcher.py index 24f30c8..5f9d928 100644 --- a/backend/app/data/fetcher.py +++ b/backend/app/data/fetcher.py @@ -113,6 +113,9 @@ async def sync_symbol( ) await session.execute(stmt) await session.commit() + # 作废 candles 相关读缓存(preview 等)——不 bump 的话旧版本号的缓存要等 TTL 自然过期 + from .. import cache + await cache.bump_version("candles") return {"symbol": code, "bars": len(bars), "source": used} diff --git a/backend/app/data/index_global.py b/backend/app/data/index_global.py index 90126a6..eb53d49 100644 --- a/backend/app/data/index_global.py +++ b/backend/app/data/index_global.py @@ -13,13 +13,13 @@ from __future__ import annotations import asyncio import json -import math import time from datetime import date, datetime, timedelta from .. import cache from ..config import settings from ..domain import Bar +from .sync_utils import d8_iso, f_clean # ---- 静态元数据表(tushare index_global 支持的全部 21 个指数,展示顺序即文档顺序)---- # region: americas 美洲 / europe 欧洲 / asia 亚太(含港股与富时A50) @@ -82,22 +82,6 @@ class GlobalIndexError(RuntimeError): """全部国际指数都拉不到(token/网络故障)——接口层转 503。""" -def _f(v) -> float | None: - """pandas 值 -> float;NaN/None -> None。""" - if v is None: - return None - try: - f = float(v) - except (TypeError, ValueError): - return None - return None if math.isnan(f) else f - - -def _d(v) -> str | None: - """YYYYMMDD -> 'YYYY-MM-DD'(字符串便于 JSON 缓存)。""" - return datetime.strptime(str(v), "%Y%m%d").date().isoformat() if v else None - - def is_cn_index(code: str) -> bool: return "." in code @@ -128,14 +112,14 @@ def _fetch_quote_sync(pro, ts_code: str) -> dict: tail = df.tail(_SPARK_DAYS) last = df.iloc[-1] return { - "close": _f(last["close"]), - "change": _f(last.get("change")), - "pct_chg": _f(last.get("pct_chg")), - "open": _f(last.get("open")), - "high": _f(last.get("high")), - "low": _f(last.get("low")), - "pre_close": _f(last.get("pre_close")), - "trade_date": _d(last["trade_date"]), + "close": f_clean(last["close"]), + "change": f_clean(last.get("change")), + "pct_chg": f_clean(last.get("pct_chg")), + "open": f_clean(last.get("open")), + "high": f_clean(last.get("high")), + "low": f_clean(last.get("low")), + "pre_close": f_clean(last.get("pre_close")), + "trade_date": d8_iso(last["trade_date"]), "spark": [round(float(c), 4) for c in tail["close"]], "spark_dates": [str(d) for d in tail["trade_date"]], } @@ -249,8 +233,8 @@ def _fetch_global_bars_sync(ts_code: str) -> list[Bar]: df = pd.concat(frames).drop_duplicates(subset="trade_date").sort_values("trade_date") bars: list[Bar] = [] for _, r in df.iterrows(): - vol = _f(r.get("vol")) - amt = _f(r.get("amount")) + vol = f_clean(r.get("vol")) + amt = f_clean(r.get("amount")) bars.append( Bar( ts=datetime.strptime(str(r["trade_date"]), "%Y%m%d"), @@ -318,9 +302,9 @@ def _fetch_basic_sync(ts_code: str) -> dict: "market": r.get("market"), "publisher": r.get("publisher"), "category": r.get("category"), - "base_date": _d(r.get("base_date")), - "base_point": _f(r.get("base_point")), - "list_date": _d(r.get("list_date")), + "base_date": d8_iso(r.get("base_date")), + "base_point": f_clean(r.get("base_point")), + "list_date": d8_iso(r.get("list_date")), } @@ -354,10 +338,10 @@ def _fetch_valuation_sync(ts_code: str, days: int) -> list[dict]: rows = [] for _, r in df.sort_values("trade_date").iterrows(): rows.append({ - "trade_date": _d(r["trade_date"]), - "pe": _f(r.get("pe")), "pe_ttm": _f(r.get("pe_ttm")), "pb": _f(r.get("pb")), - "turnover_rate": _f(r.get("turnover_rate")), - "total_mv": _f(r.get("total_mv")), "float_mv": _f(r.get("float_mv")), + "trade_date": d8_iso(r["trade_date"]), + "pe": f_clean(r.get("pe")), "pe_ttm": f_clean(r.get("pe_ttm")), "pb": f_clean(r.get("pb")), + "turnover_rate": f_clean(r.get("turnover_rate")), + "total_mv": f_clean(r.get("total_mv")), "float_mv": f_clean(r.get("float_mv")), }) return rows @@ -395,7 +379,7 @@ def _fetch_weights_sync(ts_code: str) -> dict | None: latest_date = df.iloc[0]["trade_date"] rows = df[df["trade_date"] == latest_date] return { - "trade_date": _d(latest_date), + "trade_date": d8_iso(latest_date), "total": int(len(rows)), "items": [ {"con_code": str(r["con_code"]), "weight": round(float(r["weight"]), 4)} diff --git a/backend/app/data/market_overview.py b/backend/app/data/market_overview.py index e9f3639..176f060 100644 --- a/backend/app/data/market_overview.py +++ b/backend/app/data/market_overview.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -import math import time from datetime import date, datetime, timedelta @@ -26,6 +25,7 @@ import pandas as pd from .. import cache from ..config import settings +from .sync_utils import d8_iso, f_clean # (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序 # 首页聚焦中美(港股/国际指数在 /indexes 国际指数页);标普500 腾讯符号是 s_usINX(不是 s_usSPX) @@ -57,22 +57,6 @@ class MarketOverviewError(RuntimeError): """所有指数都拉不到(token/网络故障)——接口层转 503。""" -def _f(v) -> float | None: - """pandas 值 -> float;NaN/None -> None(否则 JSON 里会出现 NaN)。""" - if v is None: - return None - try: - f = float(v) - except (TypeError, ValueError): - return None - return None if math.isnan(f) else f - - -def _d(v) -> str | None: - """YYYYMMDD -> 'YYYY-MM-DD'(字符串便于 JSON 缓存;pydantic 响应模型自动 coerce)。""" - return datetime.strptime(str(v), "%Y%m%d").date().isoformat() if v else None - - def _get_pro(): if not settings.tushare_token: raise MarketOverviewError("未配置 TUSHARE_TOKEN,无法获取大盘行情(backend/.env)") @@ -165,10 +149,10 @@ def _quote_from_df(df: pd.DataFrame) -> dict | None: tail = df.tail(_SPARK_DAYS) last = df.iloc[-1] return { - "close": _f(last["close"]), - "change": _f(last.get("change")), - "pct_chg": _f(last.get("pct_chg")), - "trade_date": _d(last["trade_date"]), + "close": f_clean(last["close"]), + "change": f_clean(last.get("change")), + "pct_chg": f_clean(last.get("pct_chg")), + "trade_date": d8_iso(last["trade_date"]), "spark": [round(float(c), 4) for c in tail["close"]], "spark_dates": [str(d) for d in tail["trade_date"]], } @@ -193,10 +177,10 @@ def _fetch_stats_sync(pro) -> dict | None: if sh_m is None or sz_m is None: return None # 两边各自取最新,日期不一致时以较旧一天为准凑齐口径(罕见,通常同日) - d = min(_d(sh_m["trade_date"]), _d(sz_m["trade_date"])) + d = min(d8_iso(sh_m["trade_date"]), d8_iso(sz_m["trade_date"])) def _sum(col: str) -> float | None: - a, b = _f(sh_m.get(col)), _f(sz_m.get(col)) + a, b = f_clean(sh_m.get(col)), f_clean(sz_m.get(col)) return None if a is None or b is None else round(a + b, 2) return { @@ -204,7 +188,7 @@ def _fetch_stats_sync(pro) -> dict | None: "total_mv": _sum("total_mv"), "float_mv": _sum("float_mv"), "amount": _sum("amount"), - "turnover": _f(sh_m.get("tr")), # 换手率仅沪市有,展示口径注明沪市 + "turnover": f_clean(sh_m.get("tr")), # 换手率仅沪市有,展示口径注明沪市 } @@ -222,7 +206,7 @@ def _fetch_amount_history_sync(pro) -> list[dict]: if len(common) == 0: return [] total = (sh_m[common] + sz_m[common]).sort_index() - return [{"date": _d(d), "amount": round(float(v), 2)} for d, v in total.tail(_AMOUNT_HIST_BARS).items()] + return [{"date": d8_iso(d), "amount": round(float(v), 2)} for d, v in total.tail(_AMOUNT_HIST_BARS).items()] # ---- EOD 的 SWR(stale-while-revalidate):新鲜期内直返;过期先返旧值后台刷新 ---- diff --git a/backend/app/data/reference.py b/backend/app/data/reference.py index 6cb18f4..7a4ee22 100644 --- a/backend/app/data/reference.py +++ b/backend/app/data/reference.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import calendar import json +import logging import time from dataclasses import dataclass from datetime import date @@ -29,6 +30,8 @@ from ..db import async_session from ..models import StockReference from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, read_sync_state, s_clean, upsert_sync_state, utcnow +log = logging.getLogger(__name__) + _REFRESH_DAYS = 7 @@ -394,12 +397,12 @@ async def _sync_repurchase_locked(only_current: bool) -> dict[str, list[dict]]: async def _repurchase_backfill() -> None: - """后台全量回填(近 24 个月);失败静默——下次触发重试。""" + """后台全量回填(近 24 个月);失败记录日志——下次触发重试。""" try: async with _repurchase_lock: await _sync_repurchase_locked(only_current=False) - except Exception: # noqa: BLE001 后台任务无人接异常 - pass + except Exception: # noqa: BLE001 后台任务无人接异常,至少留痕 + log.warning("回购数据后台回填失败(下次触发重试)", exc_info=True) def _spawn_repurchase_backfill() -> None: diff --git a/backend/app/data/sync_utils.py b/backend/app/data/sync_utils.py index 4223643..cde8f7f 100644 --- a/backend/app/data/sync_utils.py +++ b/backend/app/data/sync_utils.py @@ -68,6 +68,11 @@ def f_clean(v) -> float | None: return None if f != f else f # NaN -> None +def d8_iso(v) -> str | None: + """tushare YYYYMMDD -> 'YYYY-MM-DD'(字符串便于 JSON 缓存;pydantic 自动 coerce)。""" + return datetime.strptime(str(v), "%Y%m%d").date().isoformat() if v else None + + async def read_sync_state(session: AsyncSession, ts_code: str, kind: str) -> StockSyncState | None: return (await session.execute( select(StockSyncState).where( diff --git a/backend/app/main.py b/backend/app/main.py index e10866a..43b44d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,12 @@ """FastAPI 入口。数据库结构统一由 Alembic 管理。""" -from contextlib import asynccontextmanager +import asyncio +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text -from . import cache +from . import cache, scheduler from .api import router from .auth_api import router as auth_router from .config import settings @@ -16,7 +17,11 @@ from .db import engine async def lifespan(app: FastAPI): async with engine.connect() as conn: await conn.execute(text("SELECT 1")) + nightly = asyncio.create_task(scheduler.run_nightly_loop()) yield + nightly.cancel() + with suppress(asyncio.CancelledError): + await nightly await engine.dispose() await cache.aclose() # 释放 Redis 连接池(未启用时是 no-op) diff --git a/backend/app/models.py b/backend/app/models.py index 512e9e9..5a92066 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,8 +4,8 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库 SELECT create_hypertable('candles', 'ts'); 即可升级为时序表 + Continuous Aggregates 多周期预聚合,无需改表结构。 -智能选股三表(stock_basic / market_daily / daily_snapshot)与回测 candles(qfq) -完全隔离:选股用未复权日线按 trade_date 全市场批量落地,避免污染回测复权缓存。 +智能选股直接读 candles 不复权底座(market_daily 已退役); +daily_snapshot 存每日指标快照(估值/市值,选股过滤用)。 """ from datetime import date, datetime @@ -236,29 +236,8 @@ class StockReference(Base): ) -class MarketDaily(Base): - """全市场未复权日线(选股专用,与回测 candles(qfq) 隔离)。 - - 单位沿用 Tushare 原始:vol 手、amount 千元。 - """ - __tablename__ = "market_daily" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - trade_date: Mapped[datetime] = mapped_column(DateTime, index=True) - ts_code: Mapped[str] = mapped_column(String(12), index=True) - open: Mapped[float] = mapped_column(Float) - high: Mapped[float] = mapped_column(Float) - low: Mapped[float] = mapped_column(Float) - close: Mapped[float] = mapped_column(Float) - pre_close: Mapped[float] = mapped_column(Float) - change: Mapped[float | None] = mapped_column(Float) - pct_chg: Mapped[float | None] = mapped_column(Float) # 日涨跌幅 % - vol: Mapped[float] = mapped_column(Float) # 手 - amount: Mapped[float] = mapped_column(Float) # 千元 - - __table_args__ = ( - UniqueConstraint("ts_code", "trade_date", name="uq_mkt_code_date"), - ) +# market_daily(全市场未复权日线)已于选股改读 candles 后退役: +# ORM 模型已删,物理表暂留库中作冷备,确认无用后可手动 DROP TABLE market_daily。 class DailySnapshot(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 326b9db..4208080 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -352,6 +352,93 @@ class StockReferenceOut(BaseModel): records: list[dict[str, str | float | None]] = Field(default_factory=list) +# ---------- 打板专题(主页,同花顺口径) ---------- +class LimitStockOut(BaseModel): + """涨跌停榜单行:三池共用,涨停池字段最全(炸板/跌停池仅价格类字段)。""" + ts_code: str + name: str | None = None + price: float | None = None # 收盘价(元) + pct_chg: float | None = None # 涨跌幅 % + tag: str | None = None # 涨停标签:首板 / 2天2板(仅涨停池) + status: str | None = None # 涨停状态:一字板 / 换手板 / N连板(仅涨停池) + lu_desc: str | None = None # 涨停原因(仅涨停池) + open_num: float | None = None # 打开次数 + limit_amount_yi: float | None = None # 封单额(亿元,仅涨停池) + turnover_yi: float | None = None # 成交额(亿元,仅涨停池) + first_lu_time: str | None = None # 首次涨停时间 + last_lu_time: str | None = None # 最后涨停时间(仅炸板池) + limit_up_suc_rate: float | None = None # 近一年封板率 %(仅涨停池) + + +class LimitLadderOut(BaseModel): + ts_code: str + name: str | None = None + nums: int # 连板数 + + +class LimitBlockOut(BaseModel): + name: str | None = None # 同花顺概念板块名 + days: float | None = None # 板块连涨天数 + up_stat: str | None = None # 如「6天3板」 + cons_nums: float | None = None # 连板家数 + up_nums: float | None = None # 涨停家数 + pct_chg: float | None = None # 板块涨跌 % + + +class LimitSummaryOut(BaseModel): + up_count: int = 0 + broken_count: int = 0 + down_count: int = 0 + first_board_count: int = 0 + max_ladder: LimitLadderOut | None = None + ladder_dist: list[dict[str, int]] = Field(default_factory=list) # [{nums, count}] 升序(2板起) + + +class LimitBoardResponse(BaseModel): + trade_date: str # YYYY-MM-DD + updated_at: str + summary: LimitSummaryOut + up: list[LimitStockOut] = Field(default_factory=list) # 涨停池(按封单额降序) + broken: list[LimitStockOut] = Field(default_factory=list) # 炸板池 + down: list[LimitStockOut] = Field(default_factory=list) # 跌停池 + ladder: list[LimitLadderOut] = Field(default_factory=list) # 连板天梯(连板数降序) + blocks: list[LimitBlockOut] = Field(default_factory=list) # 涨停最强板块 + errors: list[str] = Field(default_factory=list) + + +# ---------- 概念板块(THS:ths_index 列表 + ths_daily 行情 + ths_member 成分) ---------- +class ThsBoardOut(BaseModel): + ts_code: str # 885835.TI / 700001.TI + name: str | None = None + type: str | None = None # N概念 I行业 TH主题 S特色 R地域 BB宽基 ST风格 + count: float | None = None # 成分个数 + list_date: str | None = None # YYYYMMDD + close: float | None = None # 板块指数收盘(当日快照) + pct_change: float | None = None # 涨跌幅 % + vol: float | None = None # 成交量(手) + turnover_rate: float | None = None # 换手率 % + + +class ThsBoardListResponse(BaseModel): + trade_date: str | None = None + updated_at: str | None = None + boards: list[ThsBoardOut] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + + +class ThsMemberOut(BaseModel): + con_code: str # 成分股代码 000016.SZ + con_name: str | None = None + close: float | None = None # 现价(candles 最新,北交所等无底座为空) + pct_chg: float | None = None # 涨跌幅 %(最新收盘 / 前收 - 1) + + +class ThsBoardMembersResponse(BaseModel): + code: str + name: str | None = None + members: list[ThsMemberOut] = Field(default_factory=list) + + # ---------- Auth ---------- class LoginRequest(BaseModel): username: str = Field(min_length=1, max_length=64) diff --git a/backend/app/screener/market_sync.py b/backend/app/screener/market_sync.py index eafaf6c..21867b8 100644 --- a/backend/app/screener/market_sync.py +++ b/backend/app/screener/market_sync.py @@ -10,6 +10,7 @@ daily 与 daily_basic 分步独立落库:daily_basic 积分不足时快照仍 from __future__ import annotations import asyncio +import logging import time from datetime import datetime, timedelta @@ -20,9 +21,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from .. import cache from ..config import settings from ..data.symbols import plain_code +from ..data.sync_utils import call_retry, get_pro_lazy from ..models import AdjFactor, Candle, DailySnapshot, StockBasic, TradeCalendar from .llm import ScreenerError +log = logging.getLogger(__name__) + # 进程内单例任务状态(uvicorn --reload 单进程场景够用) _sync_state: dict = { "running": False, @@ -40,32 +44,6 @@ _BATCH = 5000 # executemany 分批行数 # Tushare 积分/权限不足的特征文案(daily_basic 常见门槛) _PERM_MARKS = ("抱歉,您没有访问该项目权限", "积分", "权限") -# 频率超限特征(等待 62s 重试一次) -_RATE_MARKS = ("频率超限", "每分钟") - - -def _call_retry(fn, *args, **kwargs): - """同步调用 tushare 接口;「每分钟」级频率超限等 62s 重试一次(小时级限频直接抛)。""" - try: - return fn(*args, **kwargs) - except Exception as e: # noqa: BLE001 - msg = str(e) - if any(m in msg for m in _RATE_MARKS) and "小时" not in msg: - time.sleep(62) - return fn(*args, **kwargs) - raise - - -def _get_pro(): - """token 检查 + 返回 pro api 客户端(同步对象,调用需 to_thread 包裹)。 - - 经 tushare_provider.get_pro 统一走镜像补丁(15000 积分档 token 只认 quicksync)。 - """ - if not settings.tushare_token: - raise ScreenerError("未配置 TUSHARE_TOKEN,无法同步全市场数据(backend/.env)") - from ..data.tushare_provider import get_pro - - return get_pro() def _parse_d(s: str) -> datetime: @@ -77,7 +55,7 @@ def _fetch_calendar_sync(pro) -> list[str]: time.sleep(settings.screener_sync_interval) end = (datetime.now() + timedelta(days=90)).strftime("%Y%m%d") start = (datetime.now() - timedelta(days=550)).strftime("%Y%m%d") - cal = _call_retry(pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1") + cal = call_retry(pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1") return sorted(cal["cal_date"].tolist()) @@ -112,7 +90,7 @@ async def _recent_trade_dates(session: AsyncSession, pro, days: int) -> list[str def _fetch_daily(pro, d: str) -> list[dict]: """拉取某交易日全市场日线(未复权)。当日数据未生成(盘前/盘中)返回空。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(pro.daily, trade_date=d) + df = call_retry(pro.daily, trade_date=d) if df is None or df.empty: return [] rows = [] @@ -138,7 +116,7 @@ def _fetch_basic(pro, d: str) -> list[dict]: """ time.sleep(settings.screener_sync_interval) try: - df = _call_retry(pro.daily_basic, trade_date=d) + df = call_retry(pro.daily_basic, trade_date=d) except Exception as e: # noqa: BLE001 msg = str(e) if any(m in msg for m in _PERM_MARKS): @@ -169,7 +147,7 @@ def _fetch_basic(pro, d: str) -> list[dict]: def _fetch_adj_factor(pro, d: str) -> list[dict]: """拉取某交易日全市场复权因子(K线 bfq->qfq/hfq 本地换算的底座)。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(pro.adj_factor, trade_date=d) + df = call_retry(pro.adj_factor, trade_date=d) if df is None or df.empty: return [] return [ @@ -181,7 +159,7 @@ def _fetch_adj_factor(pro, d: str) -> list[dict]: def _sync_stock_list_sync(pro) -> list[dict]: """拉取在市股票列表。""" time.sleep(settings.screener_sync_interval) - df = _call_retry(pro.stock_basic, exchange="", list_status="L", + df = call_retry(pro.stock_basic, exchange="", list_status="L", fields="ts_code,symbol,name,area,industry,market,exchange,list_status,list_date,delist_date") rows = [] for _, r in df.iterrows(): @@ -232,6 +210,27 @@ async def _existing_candle_dates(session: AsyncSession) -> set[str]: return {r[0].strftime("%Y%m%d") for r in res if r[0] is not None} +_UPSERT_CHUNK = 3000 # 单语句行数(asyncpg 参数上限拆批) + + +async def _recent_day_counts(session: AsyncSession, dates: list[str]) -> dict[str, int]: + """指定交易日在市股票的 candles 行数(半日数据自愈用)。 + + 单条 GROUP BY 走 ts 索引范围扫,窗口 ≤15 日、代价可忽略。 + """ + if not dates: + return {} + lo = _parse_d(min(dates)) + hi = _parse_d(max(dates)) + timedelta(days=1) + rows = (await session.execute( + select(func.date(Candle.ts), func.count()) + .where(Candle.timeframe == "1d", Candle.ts >= lo, Candle.ts < hi, + Candle.symbol.in_(select(StockBasic.symbol).where(StockBasic.list_status == "L"))) + .group_by(func.date(Candle.ts)) + )).all() + return {r[0].strftime("%Y%m%d"): int(r[1]) for r in rows if r[0] is not None} + + async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: set[str], d_str: str) -> None: """把某交易日全市场日线 upsert 进 candles(不复权底座,幂等)。 @@ -253,9 +252,10 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se if not batch: return # on_conflict 语句整批渲染为占位符(非 executemany),asyncpg 单语句参数上限 32766, - # 10 列 x 3000 行 = 30000 参数留出余量 - for i in range(0, len(batch), 3000): - stmt = pg_insert(Candle).values(batch[i : i + 3000]) + # 10 列 x 3000 行 = 30000 参数留出余量。分批只拆语句,commit 在循环外 —— + # 单日一事务:写一半崩溃整日回滚,该日期语义上「未同步」,下次自然重拉(不留半日数据) + for i in range(0, len(batch), _UPSERT_CHUNK): + stmt = pg_insert(Candle).values(batch[i : i + _UPSERT_CHUNK]) stmt = stmt.on_conflict_do_update( index_elements=["symbol", "timeframe", "ts"], set_={ @@ -266,7 +266,7 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se }, ) await session.execute(stmt) - await session.commit() + await session.commit() async def _run_sync(days: int, force: bool) -> None: @@ -277,7 +277,7 @@ async def _run_sync(days: int, force: bool) -> None: from ..db import async_session # 延迟导入避免循环 try: - pro = await asyncio.to_thread(_get_pro) + pro = await asyncio.to_thread(get_pro_lazy) # 1) 股票列表(已有数据则跳过——stock_basic 低积分版限频 1 次/小时) async with async_session() as session: @@ -307,6 +307,15 @@ async def _run_sync(days: int, force: bool) -> None: select(StockBasic.symbol).where(StockBasic.list_status == "L") )).scalars() ) + if not force: + # 半日数据自愈(修单日原子化之前的历史残留):写入中途崩溃的日期 + # 行数 ≈ 1 批(3000),显著低于完整日(~5300)。只查最近 15 个交易日 + # ——实际风险区且窗口内上市数变化 <2%,0.7 阈值安全;更老的日期不查 + # (上市数变化会误判,历史缺口本就由 TDX 底座兜底)。 + counts = await _recent_day_counts(session, [d for d in dates[:15] if d in have_daily]) + if counts: + floor = max(_UPSERT_CHUNK + 1, int(max(counts.values()) * 0.7)) + have_daily -= {d for d, n in counts.items() if n < floor} todo = [d for d in dates if d not in have_daily] _sync_state["total_days"] = len(todo) _sync_state["done_days"] = 0 @@ -352,7 +361,7 @@ async def _run_sync(days: int, force: bool) -> None: try: await _refresh_stats(await cache.get_version("candles")) except Exception: # noqa: BLE001 —— 预热失败只影响统计数字的新鲜度 - pass + log.warning("统计缓存预热失败(下轮轮询会 SWR 重算)", exc_info=True) _sync_state["step"] = "同步完成" except Exception as e: # noqa: BLE001 _sync_state["error"] = f"同步失败:{str(e)[:300]}" @@ -414,16 +423,9 @@ 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) + # 写 Redis 后台执行(cache.set_bg 挂全局集合防 GC),失败由 cache 层静默降级 + cache.set_bg(f"syncstats:v{ver}", data, ttl=settings.sync_stats_redis_ttl) + cache.set_bg(_STATS_LAST_KEY, data, ttl=settings.sync_stats_redis_ttl) async def _refresh_stats(ver: int) -> None: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3f95c7d..1fe9f72 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,12 @@ dependencies = [ "openpyxl>=3.1.5", ] +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.uv] # 应用型项目(非库):不把自身打包安装,只管理依赖到 .venv package = false diff --git a/backend/scripts/test_trades_parser.py b/backend/scripts/test_trades_parser.py deleted file mode 100644 index 99282a0..0000000 --- a/backend/scripts/test_trades_parser.py +++ /dev/null @@ -1,113 +0,0 @@ -"""交割单解析器离线自测:不碰数据库,直接调 app.trades.parse_statement。 - -覆盖四类真实导出格式 + 边界行(转账/配号/利息跳过、费用合计列去重、日期多格式)。 -运行:uv run python scripts/test_trades_parser.py -""" -from __future__ import annotations - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from app.trades import parse_statement # noqa: E402 - -FAIL: list[str] = [] - - -def check(name: str, cond: bool, detail: str = "") -> None: - mark = "ok " if cond else "FAIL" - print(f"[{mark}] {name}{(' — ' + detail) if detail and not cond else ''}") - if not cond: - FAIL.append(name) - - -# ---------- 1) 通达信式:GBK + 制表符 + 标题行在前 ---------- -tdx = ( - "交割单\n" - "股东账号: A123456789 起始日期: 20240102 终止日期: 20240105 币种: 人民币\n" - "\t交割日期\t业务名称\t证券代码\t证券名称\t成交价格\t成交数量\t成交金额\t手续费\t印花税\t过户费\t发生金额\t资金余额\t合同号\n" - "\t20240102\t证券买入\t600519\t贵州茅台\t1680.00\t100\t168000.00\t5.00\t0.00\t1.68\t-168006.68\t200000.00\t1000001\n" - "\t20240102\t银行转存\t\t\t\t\t\t\t\t\t50000.00\t250000.00\t\n" - "\t20240103\t证券卖出\t600519\t贵州茅台\t1700.50\t100\t170050.00\t5.00\t170.05\t1.70\t169873.25\t419873.25\t1000002\n" - "\t20240105\t利息归本\t\t\t\t\t\t\t\t\t1.25\t419874.50\t\n" -) -r = parse_statement(tdx.encode("gbk"), "交割单.txt") -check("tdx: 2 笔成交", len(r.trades) == 2, f"got {len(r.trades)}") -check("tdx: 跳过 2 行非交易", r.skipped_other == 2, f"got {r.skipped_other}") -t0, t1 = r.trades[0], r.trades[1] -check("tdx: 日期/代码/后缀", (t0.trade_date.isoformat(), t0.ts_code) == ("2024-01-02", "600519.SH"), f"{t0.trade_date} {t0.ts_code}") -check("tdx: 买入方向+费用合计", t0.direction == "buy" and abs(t0.fee - 6.68) < 1e-9, f"{t0.direction} fee={t0.fee}") -check("tdx: 卖出费用含印花税", t1.direction == "sell" and abs(t1.fee - 176.75) < 1e-9, f"fee={t1.fee}") -check("tdx: 金额取绝对值", t0.amount == 168000.0, f"amount={t0.amount}") - -# ---------- 2) 恒生柜台式:UTF-8 CSV,交收日期/交易类别/费用合计 ---------- -hs = ( - "序号,交收日期,证券代码,证券名称,交易类别,成交价格,成交数量,证券余额,成交金额,资金发生数,资金余额,流水序号,业务标志,业务名称,发生金额,后资金额,货币类别,费用合计,净佣金,规费,印花税,过户费,合同号\n" - "1,2024-06-07,000858,五粮液,证券买入,132.50,200,200,26500.00,-26505.80,73494.20,1,0101,证券买入,-26505.80,73494.20,人民币,5.80,4.20,1.60,0.00,0.00,66778001\n" - "2,2024-06-07,,,\t,,,,5120.00,78614.20,2,2041,银行转存,5120.00,78614.20,人民币,0,0,0,0,0,\n" - "3,2024-06-10,000858,五粮液,证券卖出,135.00,200,0,27000.00,26975.30,105589.50,3,0102,证券卖出,26975.30,105589.50,人民币,24.70,4.20,1.60,18.90,0.00,66779001\n" -) -r2 = parse_statement(hs.encode("utf-8"), "hsi.csv") -check("hs: 2 笔成交", len(r2.trades) == 2, f"got {len(r2.trades)}") -check("hs: 费用合计不重复累加", abs(r2.trades[1].fee - 24.70) < 1e-9, f"fee={r2.trades[1].fee}") -check("hs: 深市后缀", r2.trades[0].ts_code == "000858.SZ", r2.trades[0].ts_code) -check("hs: 日期 YYYY-MM-DD", r2.trades[0].trade_date.isoformat() == "2024-06-07") - -# ---------- 3) HTML 伪 .xls(同花顺导出常见真身) ---------- -html = """ - - - - - -
客户姓名测试
成交日期业务名称证券代码证券名称成交价格成交数量成交金额手续费
2024/03/15证券买入300750宁德时代182.3030054,690.0016.41
2024/03/18证券卖出300750宁德时代185.0030055,500.005.55
""" -r3 = parse_statement(html.encode("gbk"), "jiaogedan.xls") -check("html: 2 笔成交", len(r3.trades) == 2, f"got {len(r3.trades)}") -check("html: 千分位金额", r3.trades[0].amount == 54690.0, f"{r3.trades[0].amount}") -check("html: 创业板后缀", r3.trades[0].ts_code == "300750.SZ", r3.trades[0].ts_code) -check("html: 斜杠日期", r3.trades[1].trade_date.isoformat() == "2024-03-18") - -# ---------- 4) 无业务名称列:发生金额正负判方向(招商式) ---------- -zh = ( - "证券名称,成交日期,成交价格,成交数量,发生金额,资金余额,合同编号\n" - "贵州茅台,20240102,1680.00,100,-168005.00,200000.00,SZ1000001\n" - "贵州茅台,20240103,1700.50,100,170049.50,370049.50,SZ1000002\n" -) -r4 = parse_statement(zh.encode("utf-8"), "zszs.csv") -check("sign: 2 笔成交", len(r4.trades) == 2, f"got {len(r4.trades)}") -check("sign: 负金额=买入", (r4.trades[0].direction, r4.trades[1].direction) == ("buy", "sell"), - f"{r4.trades[0].direction}/{r4.trades[1].direction}") - -# ---------- 5) xlsx(openpyxl 内存构造) ---------- -import io # noqa: E402 -from openpyxl import Workbook # noqa: E402 - -wb = Workbook() -ws = wb.active -ws.append(["对账单", None, None]) -ws.append(["成交日期", "业务名称", "证券代码", "证券名称", "成交均价", "成交股数", "成交金额", "佣金", "过户费"]) -from datetime import datetime as dt # noqa: E402 -ws.append([dt(2024, 2, 28, 14, 35, 0), "证券买入", "688981", "中芯国际", 52.80, 200, 10560.00, 2.50, 1.06]) -ws.append([dt(2024, 3, 1, 9, 31, 0), "证券卖出", "688981", "中芯国际", 54.10, 200, 10820.00, 2.50, 1.06]) -buf = io.BytesIO() -wb.save(buf) -r5 = parse_statement(buf.getvalue(), "sm.xlsx") -check("xlsx: 2 笔成交", len(r5.trades) == 2, f"got {len(r5.trades)}") -check("xlsx: datetime 日期", r5.trades[0].trade_date.isoformat() == "2024-02-28") -check("xlsx: 科创板后缀", r5.trades[0].ts_code == "688981.SH", r5.trades[0].ts_code) -check("xlsx: 佣金+过户费", abs(r5.trades[0].fee - 3.56) < 1e-9, f"fee={r5.trades[0].fee}") - -# ---------- 6) 错误分支 ---------- -from fastapi import HTTPException # noqa: E402 -try: - parse_statement("随便一串不是交割单的文字,1,2,3".encode("utf-8"), "x.csv") - check("garbage: 应 422", False) -except HTTPException as e: - check("garbage: 422", e.status_code == 422) - -print() -if FAIL: - print(f"FAIL {len(FAIL)}: {FAIL}") - sys.exit(1) -print("PASS: 交割单解析器全部用例通过") diff --git a/backend/uv.lock b/backend/uv.lock index 3bec112..6a13e5d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -501,6 +501,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "lxml" version = "6.1.1" @@ -719,6 +728,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pandas" version = "3.0.5" @@ -765,6 +783,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -878,6 +905,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1125,6 +1177,11 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.19.1" }, @@ -1144,6 +1201,9 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + [[package]] name = "tqdm" version = "4.70.0" diff --git a/backend_run.log b/backend_run.log deleted file mode 100644 index 6f3dc31..0000000 --- a/backend_run.log +++ /dev/null @@ -1,862 +0,0 @@ -INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend'] -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: Started reloader process [28028] using WatchFiles -INFO: Started server process [16456] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: 127.0.0.1:44639 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:44642 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44662 - "GET /api/stocks/600519.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44669 - "GET /api/stocks/600519.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44671 - "GET /api/stocks/510300.SH/company HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:44673 - "GET /api/stocks/999999.SZ/company HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:44683 - "GET /api/stocks/000002/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44686 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:44701 - "GET /api/stocks/601318.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44707 - "GET /api/stocks/601318.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44704 - "GET /api/stocks/601318.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44700 - "GET /api/stocks/601318.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:44703 - "GET /api/stocks/601318.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:45455 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:45461 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:45464 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:45463 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:45472 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:45469 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:45471 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:45476 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:45474 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -WARNING: WatchFiles detected changes in 'app\data\index_global.py'. Reloading... - INFO: 127.0.0.1:46980 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:46985 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:46982 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:46984 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:46990 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:46992 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:46991 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:46998 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:46996 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:47008 - "GET /openapi.json HTTP/1.1" 200 OK -INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend'] -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: Started reloader process [29204] using WatchFiles -INFO: Started server process [28644] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: 127.0.0.1:47041 - "GET /openapi.json HTTP/1.1" 200 OK -INFO: 127.0.0.1:47042 - "GET /api/health HTTP/1.1" 200 OK -WARNING: WatchFiles detected changes in 'scripts\_ui_drive_tmp.py'. Reloading... - INFO: 127.0.0.1:47137 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:47183 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:47185 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:47188 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:47194 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:47206 - "PUT /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:47202 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:47227 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:47233 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:47235 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:47269 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:47272 - "GET /api/market/indexes/SPX HTTP/1.1" 200 OK -INFO: 127.0.0.1:47270 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:47278 - "GET /api/market/indexes/SPX/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:47193 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:47307 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:47308 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:47310 - "GET /api/market/indexes/000300.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:47319 - "GET /api/market/indexes/000300.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:47317 - "GET /api/market/indexes/000300.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:48863 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:48877 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:48878 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:48880 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:48879 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:48886 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:48881 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:48895 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:48904 - "GET /api/market/indexes/000001.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:48912 - "GET /api/market/indexes/000001.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:48917 - "GET /api/market/indexes/000001.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49244 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:49252 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:49254 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:49253 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49260 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:49258 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:49259 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49264 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:49262 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49269 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:49266 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:49273 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49270 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:49365 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49369 - "GET /api/market/indexes/000001.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49367 - "GET /api/market/indexes/000001.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:49374 - "GET /api/market/indexes/000001.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49378 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49380 - "GET /api/market/indexes/399001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:49388 - "GET /api/market/indexes/399001.SZ/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49382 - "GET /api/market/indexes/399001.SZ/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49393 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49395 - "GET /api/market/indexes/IXIC HTTP/1.1" 200 OK -INFO: 127.0.0.1:49397 - "GET /api/market/indexes/IXIC/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49408 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49410 - "GET /api/market/indexes/RUT HTTP/1.1" 200 OK -INFO: 127.0.0.1:49412 - "GET /api/market/indexes/RUT/candles?timeframe=1d HTTP/1.1" 502 Bad Gateway -INFO: 127.0.0.1:49425 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49427 - "GET /api/market/indexes/SPTSX HTTP/1.1" 200 OK -INFO: 127.0.0.1:49429 - "GET /api/market/indexes/SPTSX/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:49437 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49441 - "GET /api/market/indexes/IBOVESPA HTTP/1.1" 200 OK -INFO: 127.0.0.1:49442 - "GET /api/market/indexes/IBOVESPA/candles?timeframe=1d HTTP/1.1" 502 Bad Gateway -INFO: 127.0.0.1:49447 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:49455 - "GET /api/market/indexes/KS11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:49457 - "GET /api/market/indexes/KS11/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64126 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:64123 - "GET /api/market/indexes/KS11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64132 - "GET /api/market/indexes/000001.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64131 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:64155 - "GET /api/market/indexes/000001.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64149 - "GET /api/market/indexes/000001.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64124 - "GET /api/market/indexes/KS11/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64374 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:64377 - "GET /api/market/indexes/KS11/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64376 - "GET /api/market/indexes/KS11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64375 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:64380 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64395 - "GET /api/market/indexes/SPTSX HTTP/1.1" 200 OK -INFO: 127.0.0.1:64397 - "GET /api/market/indexes/SPTSX/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64459 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64464 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64461 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:64469 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64465 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64482 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64489 - "GET /api/market/indexes/KS11/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64487 - "GET /api/market/indexes/KS11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64497 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64494 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:64500 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64498 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64507 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:64506 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64517 - "GET /api/trades?ts_code=000010.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:64519 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:64518 - "GET /api/screener/preview/000010.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64521 - "GET /api/screener/preview/000010.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-13 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64523 - "GET /api/stocks/000010.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:64558 - "GET /api/screener/preview/000010.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-13 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64560 - "PUT /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:64562 - "GET /api/screener/preview/000010.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-13 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64564 - "PUT /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:64802 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64799 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:64808 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64803 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64816 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:64817 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:64818 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:64825 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64828 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64826 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64865 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64871 - "GET /api/market/indexes/000001.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64869 - "GET /api/market/indexes/000001.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64876 - "GET /api/market/indexes/000001.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64917 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64918 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:64914 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:64921 - "GET /api/market/index-candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64940 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64942 - "GET /api/market/indexes/DJI HTTP/1.1" 200 OK -INFO: 127.0.0.1:64944 - "GET /api/market/indexes/DJI/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64951 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64956 - "GET /api/market/indexes/000001.SH/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64954 - "GET /api/market/indexes/000001.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:64961 - "GET /api/market/indexes/000001.SH/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64972 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:64976 - "GET /api/market/indexes/399001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:64984 - "GET /api/market/indexes/399001.SZ/weights?limit=50 HTTP/1.1" 200 OK -INFO: 127.0.0.1:64978 - "GET /api/market/indexes/399001.SZ/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:64990 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:10165 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10163 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:10167 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10252 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10249 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:10253 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10732 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10731 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:10733 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:10746 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:10751 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:10759 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:10760 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:10764 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:10770 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:10768 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: Started server process [25400] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:12480 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:12505 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12524 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 422, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - self.scope, self.receive, self.send - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 63, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\applications.py", line 1163, in __call__ - await super().__call__(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\applications.py", line 90, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__ - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\cors.py", line 88, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\routing.py", line 660, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2734, in app - await route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1780, in handle - await self.original_router.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2789, in handle - await included_router._handle_selected(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected - await original_route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1279, in handle - await app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 158, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 144, in app - response = await f(request) - ^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 481, in app - solved_result = await solve_dependencies( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - ...<6 lines>... - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\dependencies\utils.py", line 674, in solve_dependencies - solved = await call(**solved_result.values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\app\auth.py", line 127, in require_user - if expires_mono > time.monotonic() and sess_expires > utcnow() and user.is_active: - ^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 569, in __get__ - return self.impl.get(state, dict_) # type: ignore[no-any-return] - ~~~~~~~~~~~~~^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1096, in get - value = self._fire_loader_callables(state, key, passive) - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1126, in _fire_loader_callables - return state._load_expired(state, passive) - ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\state.py", line 828, in _load_expired - self.manager.expired_attribute_loader(self, toload, passive) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\loading.py", line 1607, in load_scalar_attributes - raise orm_exc.DetachedInstanceError( - ...<2 lines>... - ) -sqlalchemy.orm.exc.DetachedInstanceError: Instance is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: https://sqlalche.me/e/20/bhk3) -INFO: 127.0.0.1:12525 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 422, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - self.scope, self.receive, self.send - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 63, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\applications.py", line 1163, in __call__ - await super().__call__(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\applications.py", line 90, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__ - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\cors.py", line 88, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\routing.py", line 660, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2734, in app - await route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1780, in handle - await self.original_router.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2789, in handle - await included_router._handle_selected(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected - await original_route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1279, in handle - await app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 158, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 144, in app - response = await f(request) - ^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 481, in app - solved_result = await solve_dependencies( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - ...<6 lines>... - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\dependencies\utils.py", line 674, in solve_dependencies - solved = await call(**solved_result.values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\app\auth.py", line 127, in require_user - if expires_mono > time.monotonic() and sess_expires > utcnow() and user.is_active: - ^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 569, in __get__ - return self.impl.get(state, dict_) # type: ignore[no-any-return] - ~~~~~~~~~~~~~^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1096, in get - value = self._fire_loader_callables(state, key, passive) - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1126, in _fire_loader_callables - return state._load_expired(state, passive) - ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\state.py", line 828, in _load_expired - self.manager.expired_attribute_loader(self, toload, passive) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\loading.py", line 1607, in load_scalar_attributes - raise orm_exc.DetachedInstanceError( - ...<2 lines>... - ) -sqlalchemy.orm.exc.DetachedInstanceError: Instance is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: https://sqlalche.me/e/20/bhk3) -INFO: 127.0.0.1:12530 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\protocols\http\httptools_impl.py", line 422, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - self.scope, self.receive, self.send - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 63, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\applications.py", line 1163, in __call__ - await super().__call__(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\applications.py", line 90, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 186, in __call__ - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\cors.py", line 88, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 63, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py", line 18, in __call__ - await self.app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\routing.py", line 660, in __call__ - await self.middleware_stack(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2734, in app - await route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1780, in handle - await self.original_router.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 2789, in handle - await included_router._handle_selected(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1800, in _handle_selected - await original_route.handle(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 1279, in handle - await app(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 158, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app - raise exc - File "D:\Project\stock\backend\.venv\Lib\site-packages\starlette\_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 144, in app - response = await f(request) - ^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\routing.py", line 481, in app - solved_result = await solve_dependencies( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - ...<6 lines>... - ) - ^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\fastapi\dependencies\utils.py", line 674, in solve_dependencies - solved = await call(**solved_result.values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\app\auth.py", line 127, in require_user - if expires_mono > time.monotonic() and sess_expires > utcnow() and user.is_active: - ^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 569, in __get__ - return self.impl.get(state, dict_) # type: ignore[no-any-return] - ~~~~~~~~~~~~~^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1096, in get - value = self._fire_loader_callables(state, key, passive) - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\attributes.py", line 1126, in _fire_loader_callables - return state._load_expired(state, passive) - ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\state.py", line 828, in _load_expired - self.manager.expired_attribute_loader(self, toload, passive) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ - File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\loading.py", line 1607, in load_scalar_attributes - raise orm_exc.DetachedInstanceError( - ...<2 lines>... - ) -sqlalchemy.orm.exc.DetachedInstanceError: Instance is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: https://sqlalche.me/e/20/bhk3) -INFO: 127.0.0.1:12740 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12741 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12747 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12748 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12750 - "GET /api/stocks/000010.SZ/company HTTP/1.1" 200 OK -INFO: Started server process [20424] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:12839 - "POST /api/auth/login HTTP/1.1" 200 OK -INFO: 127.0.0.1:12841 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:12846 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12851 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:12855 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:12862 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:12861 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12857 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12874 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12870 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12873 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12886 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12887 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12888 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12889 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12893 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12894 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12971 - "GET /api/trades?ts_code=000002.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:12973 - "GET /api/screener/preview/000002.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12979 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12980 - "GET /api/stocks/510300.SH/finance HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:12975 - "GET /api/screener/preview/000002.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12972 - "GET /api/stocks/000002.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12984 - "GET /api/stocks/000002.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12983 - "GET /api/stocks/000002.SZ/finance HTTP/1.1" 200 OK -INFO: Started server process [19680] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:13034 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:13037 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:13046 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:13045 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:13044 - "GET /api/stocks/002648.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:13058 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:13060 - "GET /api/stocks/002648.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:13059 - "GET /api/stocks/002648.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:13074 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:13151 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:13152 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:13155 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:13153 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:13229 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:13154 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: Started server process [13624] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:26668 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:26677 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26680 - "GET /api/stocks/000001.SZ/reference/top10_floatholders HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26682 - "GET /api/stocks/000001.SZ/reference/pledge_stat HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26684 - "GET /api/stocks/000014.SZ/reference/pledge_detail HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26686 - "GET /api/stocks/000001.SZ/reference/share_float HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26688 - "GET /api/stocks/000001.SZ/reference/block_trade HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26693 - "GET /api/stocks/000001.SZ/reference/holdernumber HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26696 - "GET /api/stocks/000001.SZ/reference/holdertrade HTTP/1.1" 503 Service Unavailable -INFO: 127.0.0.1:26698 - "GET /api/stocks/000001.SZ/reference/shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26700 - "GET /api/stocks/000001.SZ/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26705 - "GET /api/stocks/000001.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:26707 - "GET /api/stocks/000001.SZ/reference/bogus HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:26708 - "GET /api/stocks/510300.SH/reference/top10_holders HTTP/1.1" 404 Not Found -INFO: Started server process [23460] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:26776 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:26780 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:26783 - "GET /api/stocks/000001.SZ/reference/top10_floatholders HTTP/1.1" 200 OK -INFO: 127.0.0.1:26785 - "GET /api/stocks/000001.SZ/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:26791 - "GET /api/stocks/000014.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: 127.0.0.1:26793 - "GET /api/stocks/000001.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26795 - "GET /api/stocks/000001.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:26798 - "GET /api/stocks/000001.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:26800 - "GET /api/stocks/000001.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:26802 - "GET /api/stocks/000001.SZ/reference/shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26803 - "GET /api/stocks/000001.SZ/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26805 - "GET /api/stocks/000001.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:26806 - "GET /api/stocks/000001.SZ/reference/bogus HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:26807 - "GET /api/stocks/510300.SH/reference/top10_holders HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:26819 - "GET /api/stocks/688433.SH/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26821 - "GET /api/stocks/001260.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26826 - "GET /api/stocks/000792.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: 127.0.0.1:26828 - "GET /api/stocks/000408.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: Started server process [8048] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:26862 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:26866 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:26869 - "GET /api/stocks/000001.SZ/reference/top10_floatholders HTTP/1.1" 200 OK -INFO: 127.0.0.1:26871 - "GET /api/stocks/000001.SZ/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:26873 - "GET /api/stocks/000014.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: 127.0.0.1:26893 - "GET /api/stocks/000001.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26898 - "GET /api/stocks/000001.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:26900 - "GET /api/stocks/000001.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:26910 - "GET /api/stocks/000001.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:26915 - "GET /api/stocks/000001.SZ/reference/shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26917 - "GET /api/stocks/000001.SZ/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:26919 - "GET /api/stocks/000001.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:26921 - "GET /api/stocks/000001.SZ/reference/bogus HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:26922 - "GET /api/stocks/510300.SH/reference/top10_holders HTTP/1.1" 404 Not Found -INFO: 127.0.0.1:26972 - "GET /api/stocks/688433.SH/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26975 - "GET /api/stocks/001260.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:26977 - "GET /api/stocks/000792.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: 127.0.0.1:26981 - "GET /api/stocks/000408.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: Started server process [22596] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:27093 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:27102 - "GET /api/stocks/688433.SH/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:27105 - "GET /api/stocks/001260.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:27107 - "GET /api/stocks/000001.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:27109 - "GET /api/stocks/000792.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:27154 - "GET /api/stocks/000792.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:27165 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:27169 - "GET /api/stocks/000001.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:27170 - "GET /api/stocks/000792.SZ/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:27173 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:27174 - "GET /api/stocks/000792.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:27175 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:27938 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:27949 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:27955 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:27954 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:27959 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:27960 - "GET /api/stocks/002648.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:27966 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:27965 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:27981 - "GET /api/stocks/002648.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:27980 - "GET /api/stocks/002648.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:27977 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:28003 - "GET /api/stocks/002648.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:28008 - "GET /api/stocks/002648.SZ/reference/top10_floatholders HTTP/1.1" 200 OK -INFO: 127.0.0.1:28016 - "GET /api/stocks/002648.SZ/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:28021 - "GET /api/stocks/002648.SZ/reference/pledge_detail HTTP/1.1" 200 OK -INFO: 127.0.0.1:28024 - "GET /api/stocks/002648.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:28031 - "GET /api/stocks/002648.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:28034 - "GET /api/stocks/002648.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:28039 - "GET /api/stocks/002648.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:28042 - "GET /api/stocks/002648.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:28093 - "GET /api/stocks/002648.SZ/reference/shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:28105 - "GET /api/stocks/002648.SZ/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:28989 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:28995 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:28993 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:28994 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:29003 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:29006 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:29001 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:29005 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:29015 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:29016 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:29012 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:29030 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:29032 - "GET /api/stocks/000001.SZ/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:29037 - "GET /api/stocks/000001.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:29041 - "GET /api/stocks/000001.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:29045 - "GET /api/stocks/000001.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:29077 - "GET /api/stocks/000001.SZ/reference/shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:29080 - "GET /api/stocks/000001.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: 127.0.0.1:29085 - "GET /api/stocks/000001.SZ/reference/share_float HTTP/1.1" 200 OK -INFO: 127.0.0.1:30638 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:30689 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:30659 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:30688 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:30700 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:30702 - "GET /api/stocks/002648.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:30717 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:30716 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:30729 - "GET /api/stocks/002648.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:30728 - "GET /api/stocks/002648.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:30725 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59133 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:59380 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:59474 - "GET /api/health HTTP/1.1" 200 OK -INFO: 127.0.0.1:59497 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized -INFO: 127.0.0.1:59569 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:59575 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:59578 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:59577 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59587 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:59581 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:59584 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:59586 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:59597 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:59596 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:59593 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59708 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:59715 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59716 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:59709 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:59719 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:59723 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:59721 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:59722 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59733 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:59730 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59734 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:59918 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:59923 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:59922 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59928 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59921 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:59929 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:59925 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:59933 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59926 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:59939 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:59937 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:59936 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:59946 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:59950 - "GET /api/stocks/600848.SH/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:59965 - "GET /api/stocks/600848.SH/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:59973 - "GET /api/stocks/600848.SH/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:60101 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60107 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:60106 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60112 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60102 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60113 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:60109 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:60111 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:60116 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60122 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60120 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:60119 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:60128 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:60136 - "GET /api/stocks/600848.SH/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:60153 - "GET /api/stocks/600848.SH/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:60159 - "GET /api/stocks/600848.SH/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:60237 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60243 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:60242 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60240 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60248 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60246 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:60249 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:60257 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:60247 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:60255 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60256 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:60272 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:60508 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60515 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:60514 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60510 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60523 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60521 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:60524 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:60522 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:60540 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60542 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:60541 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:60565 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:60731 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60738 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:60737 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60743 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60735 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60740 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:60744 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:60742 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:60753 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:60752 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:60749 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60761 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:60755 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60848 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60852 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60853 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:60849 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60858 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60859 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:60854 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:60856 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:60862 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60866 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:60865 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:60874 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:60990 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:60993 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:60995 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:60996 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:61001 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:61002 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:61004 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:61003 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:61017 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:61016 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:61013 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:61021 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:61113 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:61114 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:61117 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:61118 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:61123 - "GET /api/screener/preview/600848.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:61124 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:61122 - "GET /api/trades?ts_code=600848.SH HTTP/1.1" 200 OK -INFO: 127.0.0.1:61120 - "GET /api/stocks/600848.SH/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:61128 - "GET /api/screener/preview/600848.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:61131 - "GET /api/stocks/600848.SH/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:61130 - "GET /api/stocks/600848.SH/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:61144 - "GET /api/stocks/600848.SH/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:61154 - "GET /api/stocks/600848.SH/reference/pledge_stat HTTP/1.1" 200 OK -INFO: 127.0.0.1:61159 - "GET /api/stocks/600848.SH/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:61164 - "GET /api/stocks/600848.SH/reference/high_shock HTTP/1.1" 200 OK -INFO: 127.0.0.1:61735 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:61743 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:61747 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:61746 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:61751 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:61753 - "GET /api/stocks/002648.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:61757 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:61756 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:61770 - "GET /api/stocks/002648.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:61769 - "GET /api/stocks/002648.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:61766 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:61795 - "GET /api/stocks/002648.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:61807 - "GET /api/stocks/002648.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:61813 - "GET /api/stocks/002648.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:61843 - "GET /api/stocks/002648.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:61870 - "GET /api/stocks/002648.SZ/reference/repurchase HTTP/1.1" 200 OK -INFO: Started server process [43184] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:62984 - "GET /api/stocks/000001.SZ/reference/moneyflow HTTP/1.1" 200 OK -INFO: 127.0.0.1:62991 - "GET /api/stocks/000001.SZ/reference/moneyflow HTTP/1.1" 200 OK -INFO: 127.0.0.1:63245 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:63246 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:63250 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:63249 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:63260 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:63256 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:63258 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:63259 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:63276 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:63275 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:63279 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:63272 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:63281 - "GET /api/stocks/000001.SZ/reference/moneyflow HTTP/1.1" 200 OK -INFO: 127.0.0.1:11920 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:11937 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:11933 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:11936 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:11942 - "GET /api/stocks/002648.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:11941 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:11943 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:11949 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:11967 - "GET /api/stocks/002648.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:11964 - "GET /api/stocks/002648.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:11963 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12029 - "GET /api/stocks/002648.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:12088 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:12093 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:12094 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:12139 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:12155 - "GET /api/market/indexes/DJI HTTP/1.1" 200 OK -INFO: 127.0.0.1:12160 - "GET /api/market/indexes/DJI/candles?timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12223 - "GET /api/market/global-indexes HTTP/1.1" 200 OK -INFO: 127.0.0.1:12288 - "GET /api/market/overview HTTP/1.1" 200 OK -INFO: 127.0.0.1:12293 - "GET /api/screener/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:12294 - "GET /api/etf/sync/status HTTP/1.1" 200 OK -INFO: 127.0.0.1:12303 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:12302 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12309 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12314 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:12326 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:12325 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12324 - "GET /api/stocks/002100.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12333 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12335 - "GET /api/stocks/002100.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12334 - "GET /api/stocks/002100.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12344 - "GET /api/stocks/002100.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:12353 - "GET /api/stocks/002100.SZ/reference/block_trade HTTP/1.1" 200 OK -INFO: 127.0.0.1:12528 - "GET /api/stocks/002100.SZ/reference/holdertrade HTTP/1.1" 200 OK -INFO: 127.0.0.1:12543 - "GET /api/stocks/002100.SZ/reference/holdernumber HTTP/1.1" 200 OK -INFO: 127.0.0.1:12573 - "GET /api/auth/me HTTP/1.1" 200 OK -INFO: 127.0.0.1:12580 - "GET /api/stocks/facets HTTP/1.1" 200 OK -INFO: 127.0.0.1:12577 - "GET /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:12579 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12585 - "GET /api/watchlist HTTP/1.1" 200 OK -INFO: 127.0.0.1:12582 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK -INFO: 127.0.0.1:12583 - "GET /api/stocks/000001.SZ/dividends HTTP/1.1" 200 OK -INFO: 127.0.0.1:12584 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12595 - "GET /api/stocks/000001.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12594 - "GET /api/stocks/000001.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12591 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12599 - "GET /api/stocks/000001.SZ/reference/top10_holders HTTP/1.1" 200 OK -INFO: 127.0.0.1:12604 - "GET /api/stocks/000001.SZ/reference/moneyflow HTTP/1.1" 200 OK -INFO: 127.0.0.1:12607 - "GET /api/stocks/002100.SZ/reference/moneyflow HTTP/1.1" 200 OK -INFO: 127.0.0.1:12676 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2021-04-28 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12826 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2018-01-10 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12831 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1w HTTP/1.1" 200 OK -INFO: 127.0.0.1:12833 - "PUT /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:12841 - "GET /api/stocks/002100.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12842 - "GET /api/stocks/002100.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12838 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1w&end=2017-01-16 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12880 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK -INFO: 127.0.0.1:12886 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12890 - "GET /api/stocks/002100.SZ/finance HTTP/1.1" 200 OK -INFO: 127.0.0.1:12891 - "GET /api/stocks/002100.SZ/company HTTP/1.1" 200 OK -INFO: 127.0.0.1:12892 - "PUT /api/preferences HTTP/1.1" 200 OK -INFO: 127.0.0.1:12896 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2021-04-28 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12901 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2018-01-10 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12942 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-14 HTTP/1.1" 200 OK -INFO: 127.0.0.1:12944 - "PUT /api/preferences HTTP/1.1" 200 OK diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3eae377..fc030d2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,5 @@ import type { AdjustMode, - BacktestRequest, - BacktestResponse, Candle, CurrentUser, EventBacktestRequest, @@ -11,6 +9,9 @@ import type { GlobalIndexList, IndexDetail, IndexWeights, + LimitBoard, + ThsBoardList, + ThsBoardMembers, LoginRequest, LoginResponse, MarketOverview, @@ -27,8 +28,6 @@ import type { StockFinanceOut, StockReferenceOut, StockListResponse, - SyncRequest, - SyncResponse, Timeframe, TradesClearResponse, TradesImportResponse, @@ -96,18 +95,6 @@ export async function logout(): Promise { if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status); } -export async function postBacktest(req: BacktestRequest): Promise { - const res = await apiFetch('/api/backtest', { method: 'POST', body: JSON.stringify(req) }); - if (!res.ok) throw new ApiError(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`, res.status); - return (await res.json()) as BacktestResponse; -} - -export async function syncData(req: SyncRequest): Promise { - const res = await apiFetch('/api/data/sync', { method: 'POST', body: JSON.stringify(req) }); - if (!res.ok) throw new ApiError(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`, res.status); - return (await res.json()) as SyncResponse; -} - /** 自然语言事件回测。全市场扫描较慢,timeout 放宽到 15 分钟。 */ export async function postEventBacktest(req: EventBacktestRequest): Promise { const ctrl = new AbortController(); @@ -172,7 +159,7 @@ export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise< export async function getScreenerSyncStatus(): Promise { const res = await apiFetch('/api/screener/sync/status'); - if (!res.ok) throw new ApiError(`获取同步状态失败 (HTTP ${res.status})`, res.status); + if (!res.ok) throw new ApiError(await readError(res, `获取同步状态失败 (HTTP ${res.status})`), res.status); return (await res.json()) as ScreenerSyncStatus; } @@ -185,9 +172,10 @@ export async function getStockPreview( mas?: number[]; /** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */ end?: string; + signal?: AbortSignal; } = {}, ): Promise { - const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end } = opts; + const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end, signal } = opts; const q = new URLSearchParams({ limit: String(limit), adjust, @@ -195,7 +183,7 @@ export async function getStockPreview( ...(mas?.length ? { mas: mas.join(',') } : {}), ...(end ? { end } : {}), }); - const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`); + const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`, { signal }); if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status); return (await res.json()) as PreviewResponse; } @@ -235,6 +223,26 @@ export async function getMarketOverview(): Promise { return (await res.json()) as MarketOverview; } +// ---------- 打板专题(主页) ---------- +export async function getLimitBoard(): Promise { + const res = await apiFetch('/api/market/limit-board'); + if (!res.ok) throw new ApiError(await readError(res, '获取打板数据失败'), res.status); + return (await res.json()) as LimitBoard; +} + +// ---------- 概念板块(THS) ---------- +export async function getThsBoards(opts: { signal?: AbortSignal } = {}): Promise { + const res = await apiFetch('/api/market/boards', { signal: opts.signal }); + if (!res.ok) throw new ApiError(await readError(res, '获取板块列表失败'), res.status); + return (await res.json()) as ThsBoardList; +} + +export async function getThsBoardMembers(code: string, opts: { signal?: AbortSignal } = {}): Promise { + const res = await apiFetch(`/api/market/boards/${encodeURIComponent(code)}/members`, { signal: opts.signal }); + if (!res.ok) throw new ApiError(await readError(res, '获取板块成分失败'), res.status); + return (await res.json()) as ThsBoardMembers; +} + /** 上证指数全量 K 线(日线基底聚合到目标周期;收盘口径) */ export async function getIndexCandles(timeframe: Timeframe): Promise { const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`); @@ -256,9 +264,10 @@ export async function getIndexDetail(code: string): Promise { } /** 白名单指数全量 K 线(国内 index_daily / 国际 index_global,日线基底聚合) */ -export async function getAnyIndexCandles(code: string, timeframe: Timeframe): Promise { +export async function getAnyIndexCandles(code: string, timeframe: Timeframe, opts: { signal?: AbortSignal } = {}): Promise { const res = await apiFetch( `/api/market/indexes/${encodeURIComponent(code)}/candles?timeframe=${encodeURIComponent(timeframe)}`, + { signal: opts.signal }, ); if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status); return (await res.json()) as Candle[]; @@ -283,6 +292,7 @@ export async function getStocks(params: { order?: 'asc' | 'desc'; limit?: number; offset?: number; + signal?: AbortSignal; }): Promise { const q = new URLSearchParams(); if (params.search) q.set('search', params.search); @@ -294,7 +304,7 @@ export async function getStocks(params: { if (params.order) q.set('order', params.order); q.set('limit', String(params.limit ?? 100)); q.set('offset', String(params.offset ?? 0)); - const res = await apiFetch(`/api/stocks?${q.toString()}`); + const res = await apiFetch(`/api/stocks?${q.toString()}`, { signal: params.signal }); if (!res.ok) throw new ApiError(await readError(res, `获取股票列表失败 (HTTP ${res.status})`), res.status); return (await res.json()) as StockListResponse; } @@ -314,6 +324,7 @@ export async function getEtfs(params: { order?: 'asc' | 'desc'; limit?: number; offset?: number; + signal?: AbortSignal; }): Promise { const q = new URLSearchParams(); if (params.search) q.set('search', params.search); @@ -323,7 +334,7 @@ export async function getEtfs(params: { if (params.order) q.set('order', params.order); q.set('limit', String(params.limit ?? 100)); q.set('offset', String(params.offset ?? 0)); - const res = await apiFetch(`/api/etfs?${q.toString()}`); + const res = await apiFetch(`/api/etfs?${q.toString()}`, { signal: params.signal }); if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status); return (await res.json()) as EtfListResponse; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d80f4ce..c2c2f0f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -27,71 +27,6 @@ export interface Candle { turnover?: number | null; // 换手率(%);daily_basic 缺失时为 null } -export interface BacktestRequest { - symbol: string; - timeframe: string; - strategy: string; - params: Record; - initial_cash: number; - fast_mode: boolean; - start?: string; - end?: string; -} - -export interface SignalOut { - ts: string; - side: 'buy' | 'sell'; - price: number; - qty: number; -} - -export interface EquityPoint { - ts: string; - value: number; -} - -export interface IndicatorOut { - strategy: string; - data: Record; -} - -export interface MetricsOut { - total_return: number; - max_drawdown: number; - sharpe: number; - volatility: number; - num_trades: number; - win_rate: number; -} - -export interface BacktestResponse { - symbol: string; - timeframe: string; - strategy: string; - candles: Candle[]; - indicators: IndicatorOut; - signals: SignalOut[]; - equity: EquityPoint[]; - metrics: MetricsOut; - final_cash: number; - final_position: number; - initial_cash: number; -} - -export interface SyncRequest { - symbol: string; - start?: string; - end?: string; - source?: string; // auto | tushare | akshare - force?: boolean; -} - -export interface SyncResponse { - symbol: string; - bars: number; - source: string; -} - // ---------- 智能选股(镜像 app/schemas.py) ---------- export type Op = 'gt' | 'ge' | 'lt' | 'le' | 'between'; @@ -532,6 +467,94 @@ export interface MarketOverview { errors: string[]; } +// ---------- 打板专题(主页,同花顺口径) ---------- +export interface LimitStock { + ts_code: string; + name?: string | null; + price?: number | null; // 收盘价(元) + pct_chg?: number | null; // 涨跌幅 % + tag?: string | null; // 首板 / 2天2板(仅涨停池) + status?: string | null; // 一字板 / 换手板(仅涨停池) + lu_desc?: string | null; // 涨停原因(仅涨停池) + open_num?: number | null; // 打开次数 + limit_amount_yi?: number | null; // 封单额(亿元,仅涨停池) + turnover_yi?: number | null; // 成交额(亿元,仅涨停池) + first_lu_time?: string | null; // 首次涨停时间 + last_lu_time?: string | null; // 最后涨停时间(仅炸板池) + limit_up_suc_rate?: number | null; // 近一年封板率 %(仅涨停池) +} + +export interface LimitLadder { + ts_code: string; + name?: string | null; + nums: number; // 连板数 +} + +export interface LimitBlock { + name?: string | null; // 同花顺概念板块名 + days?: number | null; + up_stat?: string | null; // 如「6天3板」 + cons_nums?: number | null; // 连板家数 + up_nums?: number | null; // 涨停家数 + pct_chg?: number | null; +} + +export interface LimitBoardSummary { + up_count: number; + broken_count: number; + down_count: number; + first_board_count: number; + max_ladder?: LimitLadder | null; + ladder_dist: { nums: number; count: number }[]; // 2板起升序 +} + +export interface LimitBoard { + trade_date: string; // YYYY-MM-DD + updated_at: string; + summary: LimitBoardSummary; + up: LimitStock[]; // 涨停池(封单额降序) + broken: LimitStock[]; + down: LimitStock[]; + ladder: LimitLadder[]; // 连板数降序 + blocks: LimitBlock[]; + errors: string[]; +} + +// ---------- 概念板块(THS:ths_index + ths_daily + ths_member) ---------- +export type ThsBoardType = 'N' | 'I' | 'TH' | 'S' | 'R' | 'BB' | 'ST'; // 概念/行业/主题/特色/地域/宽基/风格 + +export interface ThsBoard { + ts_code: string; // 885835.TI / 700001.TI + name?: string | null; + type?: ThsBoardType | string | null; + count?: number | null; // 成分个数 + list_date?: string | null; + close?: number | null; // 板块指数收盘(当日快照) + pct_change?: number | null; // 涨跌幅 % + vol?: number | null; // 成交量(手) + turnover_rate?: number | null; // 换手率 % +} + +export interface ThsBoardList { + trade_date?: string | null; + updated_at?: string | null; + boards: ThsBoard[]; + errors: string[]; +} + +export interface ThsMember { + con_code: string; // 000016.SZ + con_name?: string | null; + close?: number | null; // 现价(北交所等无底座为空) + pct_chg?: number | null; // 涨跌幅 % +} + +export interface ThsBoardMembers { + code: string; + name?: string | null; + members: ThsMember[]; +} + // ---------- 指数专题(国际指数卡片 + 指数详情) ---------- export type GlobalRegion = 'americas' | 'europe' | 'asia'; diff --git a/frontend/src/components/DetailKLine.vue b/frontend/src/components/DetailKLine.vue index bcd60cf..b1f6f16 100644 --- a/frontend/src/components/DetailKLine.vue +++ b/frontend/src/components/DetailKLine.vue @@ -790,9 +790,11 @@ function teardown() { onMounted(build); onBeforeUnmount(teardown); -watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true }); +// 浅 watch 即可:父组件对 data 是整体替换(新数组引用),props 引用变化必触发; +// deep 反而每次深遍历几百根 K 线的嵌套数组(父组件从无原地改写) +watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }); // 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线) -watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers, { deep: true }); +watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers); // 涨跌配色切换:重建图表以应用新颜色 watch(() => settings.priceTone, () => { teardown(); build(); }); // 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态) diff --git a/frontend/src/components/IndexKLine.vue b/frontend/src/components/IndexKLine.vue deleted file mode 100644 index 9b8c9d8..0000000 --- a/frontend/src/components/IndexKLine.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - diff --git a/frontend/src/components/StockDetailOverlay.vue b/frontend/src/components/StockDetailOverlay.vue index 8bdbc20..349872c 100644 --- a/frontend/src/components/StockDetailOverlay.vue +++ b/frontend/src/components/StockDetailOverlay.vue @@ -420,6 +420,9 @@ const filteredItems = computed(() => { (it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q), ); }); +// 渲染截断:概念板块宽基可到 5555 只成分,全进 DOM 会卡;搜索可收敛,键盘 ↑/↓ 不受影响 +const ITEM_RENDER_CAP = 300; +const shownItems = computed(() => filteredItems.value.slice(0, ITEM_RENDER_CAP)); const activeItem = computed( () => props.items.find((it) => it.ts_code === active.value) ?? null, @@ -628,7 +631,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
无匹配
+
+ 共 {{ filteredItems.length.toLocaleString() }} 只,仅显示前 {{ ITEM_RENDER_CAP }} · 用上方搜索收敛 +
共 {{ filteredItems.length }} 只
diff --git a/frontend/src/router.ts b/frontend/src/router.ts index ba738ea..2e9b8ca 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,15 +1,15 @@ import { createRouter, createWebHistory } from 'vue-router'; -import HomeView from '@/views/HomeView.vue'; import { useAuthStore } from '@/stores/auth'; const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } }, - { path: '/', name: 'home', component: HomeView }, + { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, { path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') }, { path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') }, { path: '/etfs', name: 'etfs', component: () => import('@/views/EtfsView.vue') }, + { path: '/concepts', name: 'concepts', component: () => import('@/views/ConceptsView.vue') }, { path: '/indexes', name: 'indexes', component: () => import('@/views/IndexesView.vue') }, { path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') }, { path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') }, diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index fc49fac..dc8476d 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -38,6 +38,8 @@ function normTipFields(v: unknown): TooltipField[] | undefined { } const TF_VALID = new Set(['1d', '1w', '1M', '1y']); +const TONE_VALID = new Set(['red-up', 'green-up']); +const ADJUST_VALID = new Set(['bfq', 'qfq', 'hfq']); /** K线周期合法化;非法/缺失返回 undefined(调用方回退默认值) */ function normTimeframe(v: unknown): Timeframe | undefined { return typeof v === 'string' && TF_VALID.has(v) ? (v as Timeframe) : undefined; @@ -154,16 +156,16 @@ export const useSettingsStore = defineStore('settings', () => { const prefs = await getPreferences(); if (dirty.has('priceTone')) { schedulePush('priceTone', priceTone.value); - } else if (typeof prefs.priceTone === 'string' && prefs.priceTone !== priceTone.value) { - priceTone.value = prefs.priceTone as PriceTone; + } else if (typeof prefs.priceTone === 'string' && TONE_VALID.has(prefs.priceTone) && prefs.priceTone !== priceTone.value) { + priceTone.value = prefs.priceTone as PriceTone; // 成员已校验 saveLocal(STORAGE_KEY, prefs.priceTone); } else if (prefs.priceTone === undefined) { schedulePush('priceTone', priceTone.value); } if (dirty.has('priceAdjust')) { schedulePush('priceAdjust', priceAdjust.value); - } else if (typeof prefs.priceAdjust === 'string' && prefs.priceAdjust !== priceAdjust.value) { - priceAdjust.value = prefs.priceAdjust as PriceAdjust; + } else if (typeof prefs.priceAdjust === 'string' && ADJUST_VALID.has(prefs.priceAdjust) && prefs.priceAdjust !== priceAdjust.value) { + priceAdjust.value = prefs.priceAdjust as PriceAdjust; // 成员已校验 saveLocal(ADJUST_KEY, prefs.priceAdjust); } else if (prefs.priceAdjust === undefined) { schedulePush('priceAdjust', priceAdjust.value); diff --git a/frontend/src/views/EtfsView.vue b/frontend/src/views/EtfsView.vue index d641da8..8f78063 100644 --- a/frontend/src/views/EtfsView.vue +++ b/frontend/src/views/EtfsView.vue @@ -1,14 +1,14 @@