This commit is contained in:
2026-09-09 11:35:02 +08:00
parent bc1c72d558
commit d656c05b3d
35 changed files with 711 additions and 3194 deletions

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ Thumbs.db
# ---------- Claude ---------- # ---------- Claude ----------
.claude/settings.local.json .claude/settings.local.json
# ---------- Logs ----------
*.log

View File

@@ -9,21 +9,24 @@
## 快速启动 ## 快速启动
两个终端分别启动后端与前端: 两个终端分别启动后端与前端(命令均从仓库根 `stock/` 执行)
```bash ```bash
# 终端 1 —— 后端 APIhttp://localhost:8000 # 终端 1 —— 后端 APIhttp://localhost:8000
cd backend bash backend/restart_backend.sh
env -u SSLKEYLOGFILE uv run uvicorn app.main:app --reload --port 8000
# 终端 2 —— 前端开发服务器http://localhost:5173 # 终端 2 —— 前端开发服务器http://localhost:5173
cd frontend cd frontend
pnpm dev 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. 后端依赖 **PostgreSQL16/17+ Redis 已启动**,并配好 `backend/.env`(复制 `.env.example`)。
3. 建库表:`cd backend && uv run alembic upgrade head`
首次运行前先安装依赖:后端 `uv sync`backend 下)、前端 `pnpm install`frontend 下)。
浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。 浏览器打开 http://localhost:5173 即可使用。详细说明见下文「快速开始(开发模式)」。
--- ---
@@ -67,28 +70,36 @@ stock/
├── TECH_STACK.md # 技术选型与架构决策(必读) ├── TECH_STACK.md # 技术选型与架构决策(必读)
├── README.md # 本文件 ├── README.md # 本文件
├── backend/ # FastAPI + 回测引擎 ├── backend/ # FastAPI + 回测引擎
│ ├── pyproject.toml # uv 依赖声明 │ ├── pyproject.toml # uv 依赖声明pytest 在 dev 组)
│ ├── .env.example # 配置示例(数据库 / 费率) │ ├── .env.example # 配置示例(数据库 / 费率)
│ ├── smoke_test.py # 后端全链路自检脚本 │ ├── smoke_test.py # 后端全链路自检脚本(线上库鉴权+核心 API
│ ├── conftest.py # pytest 根(使 tests/ 可直接 import app.*
│ ├── tests/ # pytest 单元测试(交割单解析/复权换算/缓存/限速/事件回测纯函数)
│ ├── scripts/ # 一次性运维脚本TDX 导入 / 复权因子回补…)
│ └── app/ │ └── app/
│ ├── main.py # FastAPI 入口(启动建表 │ ├── main.py # FastAPI 入口(lifespan 拉起夜间调度
│ ├── config.py # 配置pydantic-settings │ ├── config.py # 配置pydantic-settings
│ ├── db.py # async SQLAlchemy 引擎/会话 │ ├── db.py # async SQLAlchemy 引擎/会话
│ ├── domain.py # 领域契约Bar/Signal/Fill/Position… │ ├── domain.py # 领域契约Bar/Signal/Fill/Position…
│ ├── models.py # ORMCandle / BacktestRun │ ├── models.py # ORMCandle / StockBasic / 用户数据表…
│ ├── schemas.py # Pydantic DTO= OpenAPI 契约) │ ├── 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/布林/均线(单一事实源) │ ├── indicators.py # 指标MACD/RSI/KDJ/布林/均线(单一事实源)
│ ├── api.py # 路由/health /candles /backtest │ ├── api/ # 路由stocks / etfs / market / backtest / screener / user + _deps 共享件
│ ├── data/ # DataProvider 适配器Tushare/AKShare+ 周期聚合 │ ├── data/ # 数据管道tushare 适配 + 同步 + 懒加载缓存)
── backtest/ # engine / broker(PaperBroker) / metrics / strategies ── screener/ # 智能选股LLM 解析 + 全市场引擎 + 夜间同步)
│ └── backtest/ # engine / events(全市场事件回测) / strategies
└── frontend/ # Vue SPA └── frontend/ # Vue SPA
├── src/ ├── src/
│ ├── main.ts # PrimeVue(Aura 深色) + Pinia │ ├── main.ts # Vue + Pinia
│ ├── api/ # 类型化客户端 + DTO 镜像 │ ├── api/ # 类型化客户端 + DTO 镜像
│ ├── stores/ # Pinia 回测状态 │ ├── stores/ # Pinia(鉴权/设置/同步状态
│ ├── components/ # KLineChart / EquityChart / MetricsPanel / BacktestForm │ ├── composables/ # 组合式工具(防抖 ref / 路由 query 同步)
── views/ # BacktestView ── components/ # StockDetailOverlay / DetailKLine / LimitBoard / MarketOverview
│ └── views/ # Home / Stocks / ETF / Concepts / Indexes / Screener / Backtest
└── vite.config.ts # /api 代理到 :8000 └── vite.config.ts # /api 代理到 :8000
``` ```
@@ -117,17 +128,16 @@ pip install uv
### 1) 后端 ### 1) 后端
实际启动命令(venv 解释器直启,日志重定向到仓库根 `backend_run.log`,改代码自动热重载 实际启动命令(一键脚本,自动杀旧进程树 + 剔除 SSLKEYLOGFILE + 热重载 + 日志写仓库根 `backend_run.log`
```bash ```bash
cd backend uv sync # 创建 .venv 并安装依赖(仅首次)
uv sync # 创建 .venv 并安装依赖(仅首次) bash backend/restart_backend.sh # 从仓库根执行Git BashWindows cmd 下等价backend\restart_backend.cmd
env -u SSLKEYLOGFILE .venv/Scripts/python.exe -m uvicorn app.main:app --reload --port 8000 > ../backend_run.log 2>&1
``` ```
看日志:`tail -f backend_run.log`;确认起没起:`curl http://localhost:8000/api/health` 看日志:`tail -f backend_run.log`;确认起没起:`curl http://localhost:8000/api/health`
等价的 `uv run` 写法(不带日志重定向,直接打到当前终端): 等价的 `uv run` 写法(不杀旧进程、日志直接打到当前终端):
```bash ```bash
cd backend 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')))"` - **`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 <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 <pid> //T //F`
- 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。 - 数据库结构由 Alembic 管理:首次部署/更新代码后先执行 `uv run alembic upgrade head`(见「初始化登录系统」)。
- 交互式 API 文档http://localhost:8000/docs - 交互式 API 文档http://localhost:8000/docs
- **夜间自动同步**:后端启动即拉起调度(`app/scheduler.py`),每日 18:05 本地时间自动跑全市场 A 股 + ETF 同步并清理过期会话;进程启动时若已过点且当日数据未落库会补跑。关闭:`.env``NIGHTLY_SYNC_ENABLED=false`,时间改 `NIGHTLY_SYNC_HOUR`
**自检**(无需起服务器,验证全链路) **测试**
```bash ```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) 前端 ### 2) 前端
@@ -152,7 +164,8 @@ pnpm install # 首次
pnpm dev # http://localhost:5173 pnpm dev # http://localhost:5173
``` ```
前端 `/api` 请求由 Vite 代理到后端 `:8000` `vite.config.ts`),无需处理跨域 - **必须用 pnpm不要用 npm/yarn**:项目锁定 `pnpm-lock.yaml``node_modules` 是 pnpm 的硬链接结构,`npm install` 会写坏依赖,导致装包/构建崩溃。以后装包一律 `pnpm add <pkg>`不要 `npm install <pkg>`
- 前端 `/api` 请求由 Vite 代理到后端 `:8000`(见 `vite.config.ts`),无需处理跨域。
打开 http://localhost:5173 → 选周期、改参数 → 点「开始回测」。 打开 http://localhost:5173 → 选周期、改参数 → 点「开始回测」。
鼠标悬停 K 线可看当日详情弹框;切日线/周线/月线/年线勾「fast 模式」可对比关闭费用/T+1 的差异。 鼠标悬停 K 线可看当日详情弹框;切日线/周线/月线/年线勾「fast 模式」可对比关闭费用/T+1 的差异。
@@ -229,9 +242,10 @@ EXPOSE_API_DOCS=false
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/health` | 健康检查 | | 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/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 ```json

View File

@@ -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智能选股DeepSeekOpenAI 兼容协议;/anthropic 后缀会被 _endpoint 自动归一)----
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyyid.secret
LLM_BASE_URL=https://api.deepseek.com
LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515
LLM_MODEL=deepseek-chat

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
"""只登录、不注册的鉴权 API。""" """只登录、不注册的鉴权 API。"""
from __future__ import annotations from __future__ import annotations
import time
from collections import deque
from datetime import timedelta from datetime import timedelta
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status 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) @router.post("/login", response_model=LoginResponse)
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,
@@ -56,6 +85,10 @@ async def login(
response: Response, response: Response,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> LoginResponse: ) -> LoginResponse:
ip = request.client.host if request.client else "?"
if _login_rate_limited(ip):
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试")
now = utcnow() now = utcnow()
username = payload.username.strip() username = payload.username.strip()
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none() user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()

View File

@@ -11,6 +11,7 @@ match=all(连续满足)/any(曾经满足),多条件之间取 AND。
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import numpy as np 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( async def run_event_backtest(
session: AsyncSession, session: AsyncSession,
spec: EventBacktestSpec, spec: EventBacktestSpec,
@@ -195,53 +259,15 @@ async def run_event_backtest(
)).all() )).all()
f_map = {(r[0], r[1].date()): float(r[2]) for r in adj_rows if r[2]} f_map = {(r[0], r[1].date()): float(r[2]) for r in adj_rows if r[2]}
bars = pd.DataFrame( # pandas 全市场扫描是同步 CPU 重计算丢线程池跑await 期间事件循环可服务其他请求)
candle_rows, columns=["symbol", "ts", "open", "high", "low", "close"] trades.extend(await asyncio.to_thread(
) _scan_batch, candle_rows, code_by_symbol, f_map, name_map,
for symbol, g in bars.groupby("symbol", sort=False): spec, start_ts, MAX_TRADES - len(trades),
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
if len(trades) >= MAX_TRADES: if len(trades) >= MAX_TRADES:
break break
stats = _stats_block(trades) stats, sample = await asyncio.to_thread(_summarize, 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 { return {
"spec": spec, "spec": spec,
"universe": ts_code or "all", "universe": ts_code or "all",

View File

@@ -1,7 +1,8 @@
"""Redis 读缓存(可选基础设施)。 """Redis 读缓存(可选基础设施)。
- REDIS_URL 留空、连接失败或超时:所有操作静默退化为「无缓存」,接口照常直查数据库 - REDIS_URL 留空、连接失败或超时:所有操作静默退化为「无缓存」,接口照常直查数据库
且本进程内禁用重试(避免每个请求都陪跑一次连接超时)。 故障后熔断 _RECOVERY_SECONDS期间所有请求直连不陪跑连接超时到期自动放行
一次探测——成功即完全恢复仍失败则重新熔断Redis 属加速件,坏了不能拖慢接口)。
- 失效策略TTL 自然过期 + 版本号INCR作废。自选股增删等写操作只 INCR 版本 key - 失效策略TTL 自然过期 + 版本号INCR作废。自选股增删等写操作只 INCR 版本 key
旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。 旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。
- 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等); - 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等);
@@ -13,6 +14,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib import hashlib
import json import json
import time import time
@@ -23,12 +25,13 @@ import redis.asyncio as aioredis
from .config import settings from .config import settings
_pool: aioredis.ConnectionPool | None = None _pool: aioredis.ConnectionPool | None = None
_disabled = False # 一次失败后本进程禁用Redis 属加速件,坏了不能拖慢接口) _RECOVERY_SECONDS = 60.0 # 熔断时长:期间直连不试 Redis到期放行一次探测
_disabled_until = 0.0 # 熔断截止的 monotonic 时刻0 = 未熔断
def _client() -> aioredis.Redis | None: def _client() -> aioredis.Redis | None:
global _pool, _disabled global _pool
if not settings.redis_url or _disabled: if not settings.redis_url or time.monotonic() < _disabled_until:
return None return None
if _pool is None: if _pool is None:
_pool = aioredis.ConnectionPool.from_url( _pool = aioredis.ConnectionPool.from_url(
@@ -43,8 +46,8 @@ def _client() -> aioredis.Redis | None:
def _bail() -> None: def _bail() -> None:
global _disabled global _disabled_until
_disabled = True _disabled_until = time.monotonic() + _RECOVERY_SECONDS
def digest(*parts: Any) -> str: 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_store[key] = (time.monotonic() + max(1, min(ttl, 120)), raw)
_local_bytes += len(raw) + 64 # 连同 dict/tuple 开销粗略计入 _local_bytes += len(raw) + 64 # 连同 dict/tuple 开销粗略计入
while _local_store and (len(_local_store) > _LOCAL_MAX_ENTRIES or _local_bytes > _LOCAL_MAX_BYTES): 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 都省掉 ------------------------ # --- 版本号本地缓存:热请求连 Redis GET ver:xx 都省掉 ------------------------

View File

@@ -56,6 +56,10 @@ class Settings(BaseSettings):
screener_default_limit: int = 200 # 选股结果条数上限 screener_default_limit: int = 200 # 选股结果条数上限
screener_sync_interval: float = 0.35 # 全市场批量调用间隔Tushare 控频 screener_sync_interval: float = 0.35 # 全市场批量调用间隔Tushare 控频
# ---- 夜间定时任务(收盘后自动同步 + 会话清理;见 app/scheduler.py----
nightly_sync_enabled: bool = True
nightly_sync_hour: int = 18 # 本地时间整点,触发在 :05收盘后日线已生成
# A股交易成本基准日 2026-08——做成可配置参数便于将来按生效日期版本化 # A股交易成本基准日 2026-08——做成可配置参数便于将来按生效日期版本化
stamp_duty_rate: float = 0.0005 # 印花税 0.05%单边卖出2023-08-28 减半) stamp_duty_rate: float = 0.0005 # 印花税 0.05%单边卖出2023-08-28 减半)
transfer_fee_rate: float = 0.00001 # 过户费 0.001%沪深双边2022 调整) transfer_fee_rate: float = 0.00001 # 过户费 0.001%沪深双边2022 调整)

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -18,12 +18,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
from ..db import async_session from ..db import async_session
from ..models import StockCompany from ..models import StockCompany
from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, s_clean, utcnow
_REFRESH_DAYS = 30 _REFRESH_DAYS = 30
# 频率超限特征(等待 62s 重试一次;与 screener.market_sync / data.etf_sync._call_retry 同款语义)
_RATE_MARKS = ("频率超限", "每分钟")
# 显式列出全部字段introduction/office/main_business/business_scope 文档标注默认不显示, # 显式列出全部字段introduction/office/main_business/business_scope 文档标注默认不显示,
# 不传 fields 时 tushare 不返回这四列(实测 000001.SZ # 不传 fields 时 tushare 不返回这四列(实测 000001.SZ
_FIELDS = ( _FIELDS = (
@@ -32,87 +30,38 @@ _FIELDS = (
"employees,main_business,business_scope" "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: def _i(v) -> int | None:
f = _f(v) f = f_clean(v)
return None if f is None else int(f) return None if f is None else int(f)
def fetch_company_sync(ts_code: str) -> dict | None: def fetch_company_sync(ts_code: str) -> dict | None:
"""同步拉单只公司简介(需在 to_thread 里跑);返回行 dict无此股返回 None。""" """同步拉单只公司简介(需在 to_thread 里跑);返回行 dict无此股返回 None。"""
time.sleep(settings.screener_sync_interval) 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: if df is None or df.empty:
return None return None
r = df.iloc[0] r = df.iloc[0]
return { return {
"ts_code": ts_code, "ts_code": ts_code,
"com_name": _s(r.get("com_name")), "com_name": s_clean(r.get("com_name")),
"com_id": _s(r.get("com_id")), "com_id": s_clean(r.get("com_id")),
"chairman": _s(r.get("chairman")), "chairman": s_clean(r.get("chairman")),
"manager": _s(r.get("manager")), "manager": s_clean(r.get("manager")),
"secretary": _s(r.get("secretary")), "secretary": s_clean(r.get("secretary")),
"reg_capital": _f(r.get("reg_capital")), "reg_capital": f_clean(r.get("reg_capital")),
"setup_date": _s(r.get("setup_date")), "setup_date": s_clean(r.get("setup_date")),
"province": _s(r.get("province")), "province": s_clean(r.get("province")),
"city": _s(r.get("city")), "city": s_clean(r.get("city")),
"introduction": _s(r.get("introduction")), "introduction": s_clean(r.get("introduction")),
"website": _s(r.get("website")), "website": s_clean(r.get("website")),
"email": _s(r.get("email")), "email": s_clean(r.get("email")),
"office": _s(r.get("office")), "office": s_clean(r.get("office")),
"employees": _i(r.get("employees")), "employees": _i(r.get("employees")),
"main_business": _s(r.get("main_business")), "main_business": s_clean(r.get("main_business")),
"business_scope": _s(r.get("business_scope")), "business_scope": s_clean(r.get("business_scope")),
"updated_at": _utcnow(), "updated_at": utcnow(),
} }
@@ -151,7 +100,7 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None:
""" """
row = (await session.execute( row = (await session.execute(
select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() 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 return _row_dict(row) if row.com_name is not None else None # 墓碑 -> None
# 释放请求会话持有的连接:后面可能隔着 1-2s 的 tushare 调用,别长占连接池。 # 释放请求会话持有的连接:后面可能隔着 1-2s 的 tushare 调用,别长占连接池。
# 用 close() 而非 rollback()rollback 会把会话身份映射里的实例全部 expire—— # 用 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: # 锁内重读 + 写入走新会话 async with async_session() as s2: # 锁内重读 + 写入走新会话
row = (await s2.execute( row = (await s2.execute(
select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none() 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 return _row_dict(row) if row.com_name is not None else None
try: try:
fetched = await asyncio.to_thread(fetch_company_sync, ts_code) 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: if row is not None and row.com_name is not None:
return _row_dict(row) return _row_dict(row)
raise 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 return fetched

View File

@@ -20,7 +20,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -29,6 +29,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache from .. import cache
from ..config import settings from ..config import settings
from . import etf_provider from . import etf_provider
from .sync_utils import call_retry, get_pro_lazy, utcnow
# 进程内单例任务状态uvicorn 单进程场景够用) # 进程内单例任务状态uvicorn 单进程场景够用)
_state: dict = { _state: dict = {
@@ -46,34 +47,6 @@ _lock = asyncio.Lock()
_BATCH = 3000 # upsert 分批行数asyncpg 单语句参数上限 3276610 列/行) _BATCH = 3000 # upsert 分批行数asyncpg 单语句参数上限 3276610 列/行)
# fund_daily 返回全市场基金 ~2100 行,一天一批远小于上限 # 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: def _parse_d(s: str) -> datetime:
return datetime.strptime(str(s), "%Y%m%d") 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: async with etf_provider.new_client() as client:
rows = await etf_provider.fetch_etf_spot(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 = pg_insert(EtfBasic).values([{**r, "updated_at": now} for r in rows])
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=["ts_code"], index_elements=["ts_code"],
@@ -108,7 +81,7 @@ async def _sync_spot(session: AsyncSession) -> int:
def _fetch_day_sync(pro, d: str) -> list[dict]: def _fetch_day_sync(pro, d: str) -> list[dict]:
"""拉某交易日全市场场内基金日线fund_daily未生成的日期返回空""" """拉某交易日全市场场内基金日线fund_daily未生成的日期返回空"""
time.sleep(settings.screener_sync_interval) 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: if df is None or df.empty:
return [] return []
rows = [] 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]: def _fetch_symbol_sync(pro, ts_code: str, start: str | None, end: str | None) -> list[dict]:
"""按 ts_code 增量/全量拉单只 ETF 日线start=None 即上市以来全量)。""" """按 ts_code 增量/全量拉单只 ETF 日线start=None 即上市以来全量)。"""
time.sleep(settings.screener_sync_interval) 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: if df is None or df.empty:
return [] return []
df = df.sort_values("trade_date") df = df.sort_values("trade_date")
@@ -209,7 +182,7 @@ async def _run_sync(full: bool) -> None:
from ..models import Candle, EtfBasic, TradeCalendar from ..models import Candle, EtfBasic, TradeCalendar
try: try:
pro = await asyncio.to_thread(_get_pro) pro = await asyncio.to_thread(get_pro_lazy)
# 1) 快照 -> etf_basic # 1) 快照 -> etf_basic
_state["step"] = "正在拉取 ETF 列表" _state["step"] = "正在拉取 ETF 列表"

View File

@@ -113,6 +113,9 @@ async def sync_symbol(
) )
await session.execute(stmt) await session.execute(stmt)
await session.commit() await session.commit()
# 作废 candles 相关读缓存preview 等)——不 bump 的话旧版本号的缓存要等 TTL 自然过期
from .. import cache
await cache.bump_version("candles")
return {"symbol": code, "bars": len(bars), "source": used} return {"symbol": code, "bars": len(bars), "source": used}

View File

@@ -13,13 +13,13 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import math
import time import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from .. import cache from .. import cache
from ..config import settings from ..config import settings
from ..domain import Bar from ..domain import Bar
from .sync_utils import d8_iso, f_clean
# ---- 静态元数据表tushare index_global 支持的全部 21 个指数,展示顺序即文档顺序)---- # ---- 静态元数据表tushare index_global 支持的全部 21 个指数,展示顺序即文档顺序)----
# region: americas 美洲 / europe 欧洲 / asia 亚太含港股与富时A50 # region: americas 美洲 / europe 欧洲 / asia 亚太含港股与富时A50
@@ -82,22 +82,6 @@ class GlobalIndexError(RuntimeError):
"""全部国际指数都拉不到token/网络故障)——接口层转 503。""" """全部国际指数都拉不到token/网络故障)——接口层转 503。"""
def _f(v) -> float | None:
"""pandas 值 -> floatNaN/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: def is_cn_index(code: str) -> bool:
return "." in code return "." in code
@@ -128,14 +112,14 @@ def _fetch_quote_sync(pro, ts_code: str) -> dict:
tail = df.tail(_SPARK_DAYS) tail = df.tail(_SPARK_DAYS)
last = df.iloc[-1] last = df.iloc[-1]
return { return {
"close": _f(last["close"]), "close": f_clean(last["close"]),
"change": _f(last.get("change")), "change": f_clean(last.get("change")),
"pct_chg": _f(last.get("pct_chg")), "pct_chg": f_clean(last.get("pct_chg")),
"open": _f(last.get("open")), "open": f_clean(last.get("open")),
"high": _f(last.get("high")), "high": f_clean(last.get("high")),
"low": _f(last.get("low")), "low": f_clean(last.get("low")),
"pre_close": _f(last.get("pre_close")), "pre_close": f_clean(last.get("pre_close")),
"trade_date": _d(last["trade_date"]), "trade_date": d8_iso(last["trade_date"]),
"spark": [round(float(c), 4) for c in tail["close"]], "spark": [round(float(c), 4) for c in tail["close"]],
"spark_dates": [str(d) for d in tail["trade_date"]], "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") df = pd.concat(frames).drop_duplicates(subset="trade_date").sort_values("trade_date")
bars: list[Bar] = [] bars: list[Bar] = []
for _, r in df.iterrows(): for _, r in df.iterrows():
vol = _f(r.get("vol")) vol = f_clean(r.get("vol"))
amt = _f(r.get("amount")) amt = f_clean(r.get("amount"))
bars.append( bars.append(
Bar( Bar(
ts=datetime.strptime(str(r["trade_date"]), "%Y%m%d"), 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"), "market": r.get("market"),
"publisher": r.get("publisher"), "publisher": r.get("publisher"),
"category": r.get("category"), "category": r.get("category"),
"base_date": _d(r.get("base_date")), "base_date": d8_iso(r.get("base_date")),
"base_point": _f(r.get("base_point")), "base_point": f_clean(r.get("base_point")),
"list_date": _d(r.get("list_date")), "list_date": d8_iso(r.get("list_date")),
} }
@@ -354,10 +338,10 @@ def _fetch_valuation_sync(ts_code: str, days: int) -> list[dict]:
rows = [] rows = []
for _, r in df.sort_values("trade_date").iterrows(): for _, r in df.sort_values("trade_date").iterrows():
rows.append({ rows.append({
"trade_date": _d(r["trade_date"]), "trade_date": d8_iso(r["trade_date"]),
"pe": _f(r.get("pe")), "pe_ttm": _f(r.get("pe_ttm")), "pb": _f(r.get("pb")), "pe": f_clean(r.get("pe")), "pe_ttm": f_clean(r.get("pe_ttm")), "pb": f_clean(r.get("pb")),
"turnover_rate": _f(r.get("turnover_rate")), "turnover_rate": f_clean(r.get("turnover_rate")),
"total_mv": _f(r.get("total_mv")), "float_mv": _f(r.get("float_mv")), "total_mv": f_clean(r.get("total_mv")), "float_mv": f_clean(r.get("float_mv")),
}) })
return rows return rows
@@ -395,7 +379,7 @@ def _fetch_weights_sync(ts_code: str) -> dict | None:
latest_date = df.iloc[0]["trade_date"] latest_date = df.iloc[0]["trade_date"]
rows = df[df["trade_date"] == latest_date] rows = df[df["trade_date"] == latest_date]
return { return {
"trade_date": _d(latest_date), "trade_date": d8_iso(latest_date),
"total": int(len(rows)), "total": int(len(rows)),
"items": [ "items": [
{"con_code": str(r["con_code"]), "weight": round(float(r["weight"]), 4)} {"con_code": str(r["con_code"]), "weight": round(float(r["weight"]), 4)}

View File

@@ -17,7 +17,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import math
import time import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -26,6 +25,7 @@ import pandas as pd
from .. import cache from .. import cache
from ..config import settings from ..config import settings
from .sync_utils import d8_iso, f_clean
# (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序 # (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序
# 首页聚焦中美(港股/国际指数在 /indexes 国际指数页标普500 腾讯符号是 s_usINX不是 s_usSPX # 首页聚焦中美(港股/国际指数在 /indexes 国际指数页标普500 腾讯符号是 s_usINX不是 s_usSPX
@@ -57,22 +57,6 @@ class MarketOverviewError(RuntimeError):
"""所有指数都拉不到token/网络故障)——接口层转 503。""" """所有指数都拉不到token/网络故障)——接口层转 503。"""
def _f(v) -> float | None:
"""pandas 值 -> floatNaN/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(): def _get_pro():
if not settings.tushare_token: if not settings.tushare_token:
raise MarketOverviewError("未配置 TUSHARE_TOKEN无法获取大盘行情backend/.env") raise MarketOverviewError("未配置 TUSHARE_TOKEN无法获取大盘行情backend/.env")
@@ -165,10 +149,10 @@ def _quote_from_df(df: pd.DataFrame) -> dict | None:
tail = df.tail(_SPARK_DAYS) tail = df.tail(_SPARK_DAYS)
last = df.iloc[-1] last = df.iloc[-1]
return { return {
"close": _f(last["close"]), "close": f_clean(last["close"]),
"change": _f(last.get("change")), "change": f_clean(last.get("change")),
"pct_chg": _f(last.get("pct_chg")), "pct_chg": f_clean(last.get("pct_chg")),
"trade_date": _d(last["trade_date"]), "trade_date": d8_iso(last["trade_date"]),
"spark": [round(float(c), 4) for c in tail["close"]], "spark": [round(float(c), 4) for c in tail["close"]],
"spark_dates": [str(d) for d in tail["trade_date"]], "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: if sh_m is None or sz_m is None:
return 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: 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 None if a is None or b is None else round(a + b, 2)
return { return {
@@ -204,7 +188,7 @@ def _fetch_stats_sync(pro) -> dict | None:
"total_mv": _sum("total_mv"), "total_mv": _sum("total_mv"),
"float_mv": _sum("float_mv"), "float_mv": _sum("float_mv"),
"amount": _sum("amount"), "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: if len(common) == 0:
return [] return []
total = (sh_m[common] + sz_m[common]).sort_index() 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 的 SWRstale-while-revalidate新鲜期内直返过期先返旧值后台刷新 ---- # ---- EOD 的 SWRstale-while-revalidate新鲜期内直返过期先返旧值后台刷新 ----

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio import asyncio
import calendar import calendar
import json import json
import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
@@ -29,6 +30,8 @@ from ..db import async_session
from ..models import StockReference 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 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 _REFRESH_DAYS = 7
@@ -394,12 +397,12 @@ async def _sync_repurchase_locked(only_current: bool) -> dict[str, list[dict]]:
async def _repurchase_backfill() -> None: async def _repurchase_backfill() -> None:
"""后台全量回填(近 24 个月);失败静默——下次触发重试。""" """后台全量回填(近 24 个月);失败记录日志——下次触发重试。"""
try: try:
async with _repurchase_lock: async with _repurchase_lock:
await _sync_repurchase_locked(only_current=False) await _sync_repurchase_locked(only_current=False)
except Exception: # noqa: BLE001 后台任务无人接异常 except Exception: # noqa: BLE001 后台任务无人接异常,至少留痕
pass log.warning("回购数据后台回填失败(下次触发重试)", exc_info=True)
def _spawn_repurchase_backfill() -> None: def _spawn_repurchase_backfill() -> None:

View File

@@ -68,6 +68,11 @@ def f_clean(v) -> float | None:
return None if f != f else f # NaN -> 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: async def read_sync_state(session: AsyncSession, ts_code: str, kind: str) -> StockSyncState | None:
return (await session.execute( return (await session.execute(
select(StockSyncState).where( select(StockSyncState).where(

View File

@@ -1,11 +1,12 @@
"""FastAPI 入口。数据库结构统一由 Alembic 管理。""" """FastAPI 入口。数据库结构统一由 Alembic 管理。"""
from contextlib import asynccontextmanager import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text from sqlalchemy import text
from . import cache from . import cache, scheduler
from .api import router from .api import router
from .auth_api import router as auth_router from .auth_api import router as auth_router
from .config import settings from .config import settings
@@ -16,7 +17,11 @@ from .db import engine
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
async with engine.connect() as conn: async with engine.connect() as conn:
await conn.execute(text("SELECT 1")) await conn.execute(text("SELECT 1"))
nightly = asyncio.create_task(scheduler.run_nightly_loop())
yield yield
nightly.cancel()
with suppress(asyncio.CancelledError):
await nightly
await engine.dispose() await engine.dispose()
await cache.aclose() # 释放 Redis 连接池(未启用时是 no-op await cache.aclose() # 释放 Redis 连接池(未启用时是 no-op

View File

@@ -4,8 +4,8 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
SELECT create_hypertable('candles', 'ts'); SELECT create_hypertable('candles', 'ts');
即可升级为时序表 + Continuous Aggregates 多周期预聚合,无需改表结构。 即可升级为时序表 + Continuous Aggregates 多周期预聚合,无需改表结构。
智能选股三表stock_basic / market_daily / daily_snapshot与回测 candles(qfq) 智能选股直接读 candles 不复权底座market_daily 已退役);
完全隔离:选股用未复权日线按 trade_date 全市场批量落地,避免污染回测复权缓存 daily_snapshot 存每日指标快照(估值/市值,选股过滤用)
""" """
from datetime import date, datetime from datetime import date, datetime
@@ -236,29 +236,8 @@ class StockReference(Base):
) )
class MarketDaily(Base): # market_daily(全市场未复权日线)已于选股改读 candles 后退役:
"""全市场未复权日线(选股专用,与回测 candles(qfq) 隔离)。 # ORM 模型已删,物理表暂留库中作冷备,确认无用后可手动 DROP TABLE market_daily。
单位沿用 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"),
)
class DailySnapshot(Base): class DailySnapshot(Base):

View File

@@ -352,6 +352,93 @@ class StockReferenceOut(BaseModel):
records: list[dict[str, str | float | None]] = Field(default_factory=list) 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)
# ---------- 概念板块THSths_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 ---------- # ---------- Auth ----------
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64) username: str = Field(min_length=1, max_length=64)

View File

@@ -10,6 +10,7 @@ daily 与 daily_basic 分步独立落库daily_basic 积分不足时快照仍
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -20,9 +21,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache from .. import cache
from ..config import settings from ..config import settings
from ..data.symbols import plain_code 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 ..models import AdjFactor, Candle, DailySnapshot, StockBasic, TradeCalendar
from .llm import ScreenerError from .llm import ScreenerError
log = logging.getLogger(__name__)
# 进程内单例任务状态uvicorn --reload 单进程场景够用) # 进程内单例任务状态uvicorn --reload 单进程场景够用)
_sync_state: dict = { _sync_state: dict = {
"running": False, "running": False,
@@ -40,32 +44,6 @@ _BATCH = 5000 # executemany 分批行数
# Tushare 积分/权限不足的特征文案daily_basic 常见门槛) # Tushare 积分/权限不足的特征文案daily_basic 常见门槛)
_PERM_MARKS = ("抱歉,您没有访问该项目权限", "积分", "权限") _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: def _parse_d(s: str) -> datetime:
@@ -77,7 +55,7 @@ def _fetch_calendar_sync(pro) -> list[str]:
time.sleep(settings.screener_sync_interval) time.sleep(settings.screener_sync_interval)
end = (datetime.now() + timedelta(days=90)).strftime("%Y%m%d") end = (datetime.now() + timedelta(days=90)).strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=550)).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()) 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]: def _fetch_daily(pro, d: str) -> list[dict]:
"""拉取某交易日全市场日线(未复权)。当日数据未生成(盘前/盘中)返回空。""" """拉取某交易日全市场日线(未复权)。当日数据未生成(盘前/盘中)返回空。"""
time.sleep(settings.screener_sync_interval) 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: if df is None or df.empty:
return [] return []
rows = [] rows = []
@@ -138,7 +116,7 @@ def _fetch_basic(pro, d: str) -> list[dict]:
""" """
time.sleep(settings.screener_sync_interval) time.sleep(settings.screener_sync_interval)
try: 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 except Exception as e: # noqa: BLE001
msg = str(e) msg = str(e)
if any(m in msg for m in _PERM_MARKS): 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]: def _fetch_adj_factor(pro, d: str) -> list[dict]:
"""拉取某交易日全市场复权因子K线 bfq->qfq/hfq 本地换算的底座)。""" """拉取某交易日全市场复权因子K线 bfq->qfq/hfq 本地换算的底座)。"""
time.sleep(settings.screener_sync_interval) 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: if df is None or df.empty:
return [] return []
return [ return [
@@ -181,7 +159,7 @@ def _fetch_adj_factor(pro, d: str) -> list[dict]:
def _sync_stock_list_sync(pro) -> list[dict]: def _sync_stock_list_sync(pro) -> list[dict]:
"""拉取在市股票列表。""" """拉取在市股票列表。"""
time.sleep(settings.screener_sync_interval) 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") fields="ts_code,symbol,name,area,industry,market,exchange,list_status,list_date,delist_date")
rows = [] rows = []
for _, r in df.iterrows(): 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} 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: async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: set[str], d_str: str) -> None:
"""把某交易日全市场日线 upsert 进 candles不复权底座幂等 """把某交易日全市场日线 upsert 进 candles不复权底座幂等
@@ -253,9 +252,10 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se
if not batch: if not batch:
return return
# on_conflict 语句整批渲染为占位符(非 executemanyasyncpg 单语句参数上限 32766 # on_conflict 语句整批渲染为占位符(非 executemanyasyncpg 单语句参数上限 32766
# 10 列 x 3000 行 = 30000 参数留出余量 # 10 列 x 3000 行 = 30000 参数留出余量。分批只拆语句commit 在循环外 ——
for i in range(0, len(batch), 3000): # 单日一事务:写一半崩溃整日回滚,该日期语义上「未同步」,下次自然重拉(不留半日数据)
stmt = pg_insert(Candle).values(batch[i : i + 3000]) 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( stmt = stmt.on_conflict_do_update(
index_elements=["symbol", "timeframe", "ts"], index_elements=["symbol", "timeframe", "ts"],
set_={ set_={
@@ -266,7 +266,7 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se
}, },
) )
await session.execute(stmt) await session.execute(stmt)
await session.commit() await session.commit()
async def _run_sync(days: int, force: bool) -> None: 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 # 延迟导入避免循环 from ..db import async_session # 延迟导入避免循环
try: try:
pro = await asyncio.to_thread(_get_pro) pro = await asyncio.to_thread(get_pro_lazy)
# 1) 股票列表已有数据则跳过——stock_basic 低积分版限频 1 次/小时) # 1) 股票列表已有数据则跳过——stock_basic 低积分版限频 1 次/小时)
async with async_session() as session: 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") select(StockBasic.symbol).where(StockBasic.list_status == "L")
)).scalars() )).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] todo = [d for d in dates if d not in have_daily]
_sync_state["total_days"] = len(todo) _sync_state["total_days"] = len(todo)
_sync_state["done_days"] = 0 _sync_state["done_days"] = 0
@@ -352,7 +361,7 @@ async def _run_sync(days: int, force: bool) -> None:
try: try:
await _refresh_stats(await cache.get_version("candles")) await _refresh_stats(await cache.get_version("candles"))
except Exception: # noqa: BLE001 —— 预热失败只影响统计数字的新鲜度 except Exception: # noqa: BLE001 —— 预热失败只影响统计数字的新鲜度
pass log.warning("统计缓存预热失败(下轮轮询会 SWR 重算)", exc_info=True)
_sync_state["step"] = "同步完成" _sync_state["step"] = "同步完成"
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
_sync_state["error"] = f"同步失败:{str(e)[:300]}" _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: async def _store_stats(ver: int, data: dict) -> None:
_status_stats_cache.update(at=time.time(), ver=ver, data=data) _status_stats_cache.update(at=time.time(), ver=ver, data=data)
# 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢调用方 # 写 Redis 后台执行cache.set_bg 挂全局集合防 GC,失败由 cache 层静默降级
tasks = [ cache.set_bg(f"syncstats:v{ver}", data, ttl=settings.sync_stats_redis_ttl)
asyncio.create_task(cache.cache_set( cache.set_bg(_STATS_LAST_KEY, data, ttl=settings.sync_stats_redis_ttl)
f"syncstats:v{ver}", data, ttl=settings.sync_stats_redis_ttl)),
asyncio.create_task(cache.cache_set(
_STATS_LAST_KEY, data, ttl=settings.sync_stats_redis_ttl)),
]
_stats_bg_tasks.update(tasks)
for t in tasks:
t.add_done_callback(_stats_bg_tasks.discard)
async def _refresh_stats(ver: int) -> None: async def _refresh_stats(ver: int) -> None:

View File

@@ -21,6 +21,12 @@ dependencies = [
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
] ]
[dependency-groups]
dev = ["pytest>=8"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.uv] [tool.uv]
# 应用型项目(非库):不把自身打包安装,只管理依赖到 .venv # 应用型项目(非库):不把自身打包安装,只管理依赖到 .venv
package = false package = false

View File

@@ -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 = """<html><head><meta charset="gbk"></head><body>
<table>
<tr><td>客户姓名</td><td>测试</td></tr>
<tr><td>成交日期</td><td>业务名称</td><td>证券代码</td><td>证券名称</td><td>成交价格</td><td>成交数量</td><td>成交金额</td><td>手续费</td></tr>
<tr><td>2024/03/15</td><td>证券买入</td><td>300750</td><td>宁德时代</td><td>182.30</td><td>300</td><td>54,690.00</td><td>16.41</td></tr>
<tr><td>2024/03/18</td><td>证券卖出</td><td>300750</td><td>宁德时代</td><td>185.00</td><td>300</td><td>55,500.00</td><td>5.55</td></tr>
</table></body></html>"""
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) xlsxopenpyxl 内存构造) ----------
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: 交割单解析器全部用例通过")

60
backend/uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "lxml" name = "lxml"
version = "6.1.1" 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" }, { 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]] [[package]]
name = "pandas" name = "pandas"
version = "3.0.5" 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" }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" 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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -1125,6 +1177,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.19.1" }, { name = "alembic", specifier = ">=1.19.1" },
@@ -1144,6 +1201,9 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.70.0" version = "4.70.0"

View File

@@ -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 <User at 0x21243a634d0> 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 <User at 0x21243a634d0> 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 <User at 0x21243a634d0> 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

View File

@@ -1,7 +1,5 @@
import type { import type {
AdjustMode, AdjustMode,
BacktestRequest,
BacktestResponse,
Candle, Candle,
CurrentUser, CurrentUser,
EventBacktestRequest, EventBacktestRequest,
@@ -11,6 +9,9 @@ import type {
GlobalIndexList, GlobalIndexList,
IndexDetail, IndexDetail,
IndexWeights, IndexWeights,
LimitBoard,
ThsBoardList,
ThsBoardMembers,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
MarketOverview, MarketOverview,
@@ -27,8 +28,6 @@ import type {
StockFinanceOut, StockFinanceOut,
StockReferenceOut, StockReferenceOut,
StockListResponse, StockListResponse,
SyncRequest,
SyncResponse,
Timeframe, Timeframe,
TradesClearResponse, TradesClearResponse,
TradesImportResponse, TradesImportResponse,
@@ -96,18 +95,6 @@ export async function logout(): Promise<void> {
if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status); if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status);
} }
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
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<SyncResponse> {
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 分钟。 */ /** 自然语言事件回测。全市场扫描较慢timeout 放宽到 15 分钟。 */
export async function postEventBacktest(req: EventBacktestRequest): Promise<EventBacktestResponse> { export async function postEventBacktest(req: EventBacktestRequest): Promise<EventBacktestResponse> {
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -172,7 +159,7 @@ export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<
export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> { export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
const res = await apiFetch('/api/screener/sync/status'); 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; return (await res.json()) as ScreenerSyncStatus;
} }
@@ -185,9 +172,10 @@ export async function getStockPreview(
mas?: number[]; mas?: number[];
/** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */ /** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */
end?: string; end?: string;
signal?: AbortSignal;
} = {}, } = {},
): Promise<PreviewResponse> { ): Promise<PreviewResponse> {
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({ const q = new URLSearchParams({
limit: String(limit), limit: String(limit),
adjust, adjust,
@@ -195,7 +183,7 @@ export async function getStockPreview(
...(mas?.length ? { mas: mas.join(',') } : {}), ...(mas?.length ? { mas: mas.join(',') } : {}),
...(end ? { end } : {}), ...(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); if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as PreviewResponse; return (await res.json()) as PreviewResponse;
} }
@@ -235,6 +223,26 @@ export async function getMarketOverview(): Promise<MarketOverview> {
return (await res.json()) as MarketOverview; return (await res.json()) as MarketOverview;
} }
// ---------- 打板专题(主页) ----------
export async function getLimitBoard(): Promise<LimitBoard> {
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<ThsBoardList> {
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<ThsBoardMembers> {
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 线(日线基底聚合到目标周期;收盘口径) */ /** 上证指数全量 K 线(日线基底聚合到目标周期;收盘口径) */
export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> { export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`); const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`);
@@ -256,9 +264,10 @@ export async function getIndexDetail(code: string): Promise<IndexDetail> {
} }
/** 白名单指数全量 K 线(国内 index_daily / 国际 index_global日线基底聚合 */ /** 白名单指数全量 K 线(国内 index_daily / 国际 index_global日线基底聚合 */
export async function getAnyIndexCandles(code: string, timeframe: Timeframe): Promise<Candle[]> { export async function getAnyIndexCandles(code: string, timeframe: Timeframe, opts: { signal?: AbortSignal } = {}): Promise<Candle[]> {
const res = await apiFetch( const res = await apiFetch(
`/api/market/indexes/${encodeURIComponent(code)}/candles?timeframe=${encodeURIComponent(timeframe)}`, `/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); if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as Candle[]; return (await res.json()) as Candle[];
@@ -283,6 +292,7 @@ export async function getStocks(params: {
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
limit?: number; limit?: number;
offset?: number; offset?: number;
signal?: AbortSignal;
}): Promise<StockListResponse> { }): Promise<StockListResponse> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.search) q.set('search', params.search); 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); if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100)); q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0)); 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); if (!res.ok) throw new ApiError(await readError(res, `获取股票列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockListResponse; return (await res.json()) as StockListResponse;
} }
@@ -314,6 +324,7 @@ export async function getEtfs(params: {
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
limit?: number; limit?: number;
offset?: number; offset?: number;
signal?: AbortSignal;
}): Promise<EtfListResponse> { }): Promise<EtfListResponse> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.search) q.set('search', params.search); 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); if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100)); q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0)); 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); if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfListResponse; return (await res.json()) as EtfListResponse;
} }

View File

@@ -27,71 +27,6 @@ export interface Candle {
turnover?: number | null; // 换手率(%daily_basic 缺失时为 null turnover?: number | null; // 换手率(%daily_basic 缺失时为 null
} }
export interface BacktestRequest {
symbol: string;
timeframe: string;
strategy: string;
params: Record<string, number>;
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<string, (number | null)[]>;
}
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 ---------- // ---------- 智能选股(镜像 app/schemas.py ----------
export type Op = 'gt' | 'ge' | 'lt' | 'le' | 'between'; export type Op = 'gt' | 'ge' | 'lt' | 'le' | 'between';
@@ -532,6 +467,94 @@ export interface MarketOverview {
errors: string[]; 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[];
}
// ---------- 概念板块THSths_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'; export type GlobalRegion = 'americas' | 'europe' | 'asia';

View File

@@ -790,9 +790,11 @@ function teardown() {
onMounted(build); onMounted(build);
onBeforeUnmount(teardown); 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(); }); watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态) // 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)

View File

@@ -1,133 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, type Chart, type KLineData } from 'klinecharts';
import { getIndexCandles } from '@/api/client';
import type { Candle, Timeframe } from '@/api/types';
import { useSettingsStore } from '@/stores/settings';
import { darkStyles } from '@/chartStyles';
// 首页上证指数 K 线图:轻量版(无翻页/画线/副图配置)。
// v10 无 applyNewData数据只进 dataLoader——每次到新数据整图重建切周期/换配色同款,
// 与详情页 teardown+build 模式一致;全量 ≤9000 根init 开销毫秒级)。
// 周期随用户偏好持久化chartLayout.indexTimeframe
const settings = useSettingsStore();
const PERIODS: { key: Timeframe; label: string }[] = [
{ key: '1d', label: '日K' },
{ key: '1w', label: '周K' },
{ key: '1M', label: '月K' },
{ key: '1y', label: '年K' },
];
const timeframe = ref<Timeframe>(settings.chartLayout.indexTimeframe ?? '1d');
function setTimeframe(tf: Timeframe) {
if (tf === timeframe.value) return;
timeframe.value = tf;
settings.setChartLayout({ indexTimeframe: tf });
void load();
}
const container = ref<HTMLDivElement | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const lastDate = ref(''); // 数据末根交易日(收盘口径)
let chart: Chart | null = null;
let loadToken = 0;
let lastCandles: Candle[] | null = null; // 配色切换重建图表时免重拉
function rebuild(candles: Candle[]) {
if (!container.value) return;
if (chart) { dispose(container.value); chart = null; }
const ch = init(container.value, { styles: darkStyles(settings.upHex, settings.downHex) });
if (!ch) return;
chart = ch;
const data: KLineData[] = candles.map((c) => ({
timestamp: new Date(c.ts).getTime(),
open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume,
}));
// 全量已在手init 一次给足forward更早历史/backward更新端都无更多
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'init') callback(data, { forward: false, backward: false });
else callback([], { forward: false, backward: false });
},
});
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载
ch.setSymbol({ ticker: '000001.SH' });
ch.setPeriod({ type: 'day', span: 1 });
// 主图 MA周期与详情页默认一致+ VOL 副图;右侧留白与详情页同款
ch.createIndicator({ name: 'MA', paneId: 'candle_pane', calcParams: [5, 10, 20, 60] });
ch.createIndicator('VOL');
const volPane = ch.getIndicators().find((i) => i.name === 'VOL')?.paneId;
ch.setPaneOptions({ id: 'candle_pane', height: 252, minHeight: 160 });
if (volPane) ch.setPaneOptions({ id: volPane, height: 76, minHeight: 56 });
ch.setOffsetRightDistance(28);
}
async function load() {
const token = ++loadToken;
loading.value = true;
error.value = null;
try {
const candles = await getIndexCandles(timeframe.value);
if (token !== loadToken) return; // 期间已切换周期,旧响应丢弃
lastCandles = candles;
rebuild(candles);
lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : '';
} catch (e) {
if (token === loadToken) error.value = e instanceof Error ? e.message : '获取指数K线失败';
} finally {
if (token === loadToken) loading.value = false;
}
}
onMounted(load);
onBeforeUnmount(() => {
if (container.value) dispose(container.value);
chart = null;
});
// 涨跌配色切换:重建图表应用新颜色,数据用已拉到的直接重放
watch(() => settings.priceTone, () => {
if (lastCandles) rebuild(lastCandles);
});
</script>
<template>
<div class="rounded-lg border border-[#26272E] bg-[#101014] p-3">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium text-[#E5E7EB]">上证指数</span>
<span v-if="lastDate" class="font-mono text-xs tabular-nums text-[#6B7280]">收盘口径 · {{ lastDate }}</span>
<span v-else class="text-xs text-[#6B7280]">收盘口径</span>
</div>
<div class="flex items-center gap-1">
<button
v-for="p in PERIODS"
:key="p.key"
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="timeframe === p.key
? 'border-blue-600 bg-blue-600 text-white'
: 'border-[#26272E] bg-[#101014] text-[#9BA3AE] hover:text-[#E5E7EB]'"
@click="setTimeframe(p.key)"
>{{ p.label }}</button>
</div>
</div>
<div class="relative h-[340px]">
<div ref="container" class="h-full w-full" />
<div
v-if="loading"
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/70 text-sm text-[#9BA3AE]"
>
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
指数K线加载中
</div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-[#A8AFB8]">
{{ error }}
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="load">重试</button>
</div>
</div>
</div>
</template>

View File

@@ -420,6 +420,9 @@ const filteredItems = computed(() => {
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q), (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( const activeItem = computed(
() => props.items.find((it) => it.ts_code === active.value) ?? null, () => 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,
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
<button <button
v-for="it in filteredItems" v-for="it in shownItems"
:key="it.ts_code" :key="it.ts_code"
type="button" type="button"
class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors" class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
@@ -647,6 +650,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</span> </span>
</button> </button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div> <div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div>
<div v-else-if="filteredItems.length > ITEM_RENDER_CAP" class="px-3 py-2 text-center text-[11px] text-[#6B7280]">
{{ filteredItems.length.toLocaleString() }} 仅显示前 {{ ITEM_RENDER_CAP }} · 用上方搜索收敛
</div>
</div> </div>
<div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]"> {{ filteredItems.length }} </div> <div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]"> {{ filteredItems.length }} </div>
</aside> </aside>

View File

@@ -1,15 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } }, { 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: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') }, { path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') },
{ path: '/etfs', name: 'etfs', component: () => import('@/views/EtfsView.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', name: 'indexes', component: () => import('@/views/IndexesView.vue') },
{ path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') }, { path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') }, { path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },

View File

@@ -38,6 +38,8 @@ function normTipFields(v: unknown): TooltipField[] | undefined {
} }
const TF_VALID = new Set(['1d', '1w', '1M', '1y']); 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调用方回退默认值 */ /** K线周期合法化非法/缺失返回 undefined调用方回退默认值 */
function normTimeframe(v: unknown): Timeframe | undefined { function normTimeframe(v: unknown): Timeframe | undefined {
return typeof v === 'string' && TF_VALID.has(v) ? (v as 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(); const prefs = await getPreferences();
if (dirty.has('priceTone')) { if (dirty.has('priceTone')) {
schedulePush('priceTone', priceTone.value); schedulePush('priceTone', priceTone.value);
} else if (typeof prefs.priceTone === 'string' && prefs.priceTone !== priceTone.value) { } else if (typeof prefs.priceTone === 'string' && TONE_VALID.has(prefs.priceTone) && prefs.priceTone !== priceTone.value) {
priceTone.value = prefs.priceTone as PriceTone; priceTone.value = prefs.priceTone as PriceTone; // 成员已校验
saveLocal(STORAGE_KEY, prefs.priceTone); saveLocal(STORAGE_KEY, prefs.priceTone);
} else if (prefs.priceTone === undefined) { } else if (prefs.priceTone === undefined) {
schedulePush('priceTone', priceTone.value); schedulePush('priceTone', priceTone.value);
} }
if (dirty.has('priceAdjust')) { if (dirty.has('priceAdjust')) {
schedulePush('priceAdjust', priceAdjust.value); schedulePush('priceAdjust', priceAdjust.value);
} else if (typeof prefs.priceAdjust === 'string' && prefs.priceAdjust !== priceAdjust.value) { } else if (typeof prefs.priceAdjust === 'string' && ADJUST_VALID.has(prefs.priceAdjust) && prefs.priceAdjust !== priceAdjust.value) {
priceAdjust.value = prefs.priceAdjust as PriceAdjust; priceAdjust.value = prefs.priceAdjust as PriceAdjust; // 成员已校验
saveLocal(ADJUST_KEY, prefs.priceAdjust); saveLocal(ADJUST_KEY, prefs.priceAdjust);
} else if (prefs.priceAdjust === undefined) { } else if (prefs.priceAdjust === undefined) {
schedulePush('priceAdjust', priceAdjust.value); schedulePush('priceAdjust', priceAdjust.value);

View File

@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute } from 'vue-router';
import { addWatchlist, getEtfs, removeWatchlist } from '@/api/client'; import { addWatchlist, getEtfs, removeWatchlist } from '@/api/client';
import type { EtfListItem, ScreenerItemOut } from '@/api/types'; import type { EtfListItem, ScreenerItemOut } from '@/api/types';
import { useQuerySync } from '@/composables/useQuerySync';
import EtfSyncBar from '@/components/EtfSyncBar.vue'; import EtfSyncBar from '@/components/EtfSyncBar.vue';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue'; import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ---------- // ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute(); const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined { function qStr(key: string): string | undefined {
const v = route.query[key]; const v = route.query[key];
@@ -58,10 +58,14 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
// ---------- 加载(搜索防抖) ---------- // ---------- 加载(搜索防抖) ----------
let fetchToken = 0; let fetchToken = 0;
let fetchCtrl: AbortController | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined; let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() { async function load() {
const token = ++fetchToken; const token = ++fetchToken;
fetchCtrl?.abort();
const ctrl = new AbortController();
fetchCtrl = ctrl;
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@@ -74,13 +78,17 @@ async function load() {
order: order.value, order: order.value,
limit: pageSize, limit: pageSize,
offset: (page.value - 1) * pageSize, offset: (page.value - 1) * pageSize,
signal: ctrl.signal,
}); });
if (token === fetchToken) { if (token === fetchToken) {
items.value = res.items; items.value = res.items;
total.value = res.total; total.value = res.total;
} }
} catch (e) { } catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败'; // AbortError = 被更新的请求取消静默token 检查通常已挡住,这里双保险)
if (token === fetchToken && !(e instanceof Error && e.name === 'AbortError')) {
error.value = e instanceof Error ? e.message : '加载失败';
}
} finally { } finally {
if (token === fetchToken) loading.value = false; if (token === fetchToken) loading.value = false;
} }
@@ -169,43 +177,30 @@ function onWatchedChange() {
load(); load();
} }
// ---------- 路由同步:状态 → ?q/&market/…replace 不产生历史记录) ---------- // ---------- 路由同步:状态 → ?q/&market/…(骨架在 useQuerySyncreplace 不产生历史记录) ----------
function buildQuery(): Record<string, string> { const { syncRoute } = useQuerySync({
const q: Record<string, string> = {}; sources: [search, market, sort, order, page],
if (search.value.trim()) q.q = search.value.trim(); buildQuery: () => {
if (market.value !== '全部') q.market = market.value; const q: Record<string, string> = {};
if (sort.value !== 'total_mv') q.sort = sort.value; if (search.value.trim()) q.q = search.value.trim();
if (order.value !== 'desc') q.order = order.value; if (market.value !== '全部') q.market = market.value;
if (page.value > 1) q.page = String(page.value); if (sort.value !== 'total_mv') q.sort = sort.value;
if (previewCode.value) q.code = previewCode.value; if (order.value !== 'desc') q.order = order.value;
return q; if (page.value > 1) q.page = String(page.value);
} if (previewCode.value) q.code = previewCode.value;
return q;
let selfNav = 0; // 自己发起的导航在途数量:其 route 变化不回灌状态(防输入被旧 URL 覆盖) },
function syncRoute(push = false) { // 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
const query = buildQuery(); applyQuery: (q) => {
// 与当前 URL 一致就跳过,避免 state→route→state 回声 search.value = q.q ?? '';
if (JSON.stringify(query) === JSON.stringify(route.query)) return; market.value = MARKETS.includes(q.market ?? '') ? (q.market as typeof MARKETS[number]) : '全部';
selfNav++; const p = parseInt(q.page ?? '', 10);
const done = () => { selfNav--; }; page.value = Number.isFinite(p) && p >= 1 ? p : 1;
void (push ? router.push({ query }) : router.replace({ query })).then(done, done); const s = q.sort ?? 'total_mv';
} sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'total_mv';
order.value = q.order === 'asc' ? 'asc' : 'desc';
// 列表状态变化(含搜索防抖外的输入)随手回写 URL翻页/筛选也带着当前 ?code previewCode.value = q.code || null;
watch([search, market, sort, order, page], () => syncRoute()); },
// 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
watch(() => route.query, (q) => {
if (selfNav > 0) return;
const qOf = (k: string) => (typeof q[k] === 'string' ? (q[k] as string) : '');
search.value = qOf('q');
market.value = MARKETS.includes(qOf('market')) ? qOf('market') : '全部';
const p = parseInt(qOf('page'), 10);
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = qOf('sort');
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'total_mv';
order.value = qOf('order') === 'asc' ? 'asc' : 'desc';
previewCode.value = qOf('code') || null;
}); });
// ---------- 详情浮层开关(写入 ?code= ---------- // ---------- 详情浮层开关(写入 ?code= ----------

View File

@@ -3,8 +3,9 @@ import { RouterLink } from 'vue-router';
import MarketOverview from '@/components/MarketOverview.vue'; import MarketOverview from '@/components/MarketOverview.vue';
import MarketSyncBar from '@/components/MarketSyncBar.vue'; import MarketSyncBar from '@/components/MarketSyncBar.vue';
import LimitBoard from '@/components/LimitBoard.vue';
// 首页:大盘行情总览 + 功能入口(看股 / ETF / 选股 / 回测 // 首页:功能入口置顶(看股 / ETF / 选股 / 回测 / 概念板块)+ 大盘行情总览 + 打板专题
const features = [ const features = [
{ {
to: '/stocks', to: '/stocks',
@@ -27,6 +28,13 @@ const features = [
title: '选股', title: '选股',
desc: '用自然语言描述选股条件,快速完成全市场筛选。', desc: '用自然语言描述选股条件,快速完成全市场筛选。',
}, },
{
to: '/concepts',
icon: 'M7 7h.01M7 3h5c1.4 0 2.6.6 3.4 1.6l2 2.8c.6.8.6 2 0 2.8l-2 2.8c-.8 1-2 1.6-3.4 1.6H7c-2.2 0-4-1.8-4-4V7c0-2.2 1.8-4 4-4zM17 21v-4M14 21h6',
accent: 'bg-rose-500/15 text-rose-300',
title: '概念板块',
desc: '同花顺概念/行业分类,看成分股行情并进入个股研究。',
},
{ {
to: '/backtest', to: '/backtest',
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6', icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
@@ -39,11 +47,8 @@ const features = [
<template> <template>
<div class="w-full max-w-6xl"> <div class="w-full max-w-6xl">
<MarketOverview /> <!-- 功能入口置顶一屏内即可点进看股/选股等页面 -->
<div class="mb-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<MarketSyncBar />
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<RouterLink <RouterLink
v-for="f in features" v-for="f in features"
:key="f.to" :key="f.to"
@@ -51,19 +56,25 @@ const features = [
class="group flex flex-1 flex-col rounded-lg border border-[#26272E] bg-[#101014] p-6 transition-all hover:-translate-y-1 hover:border-[#3A3D46] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:p-7" class="group flex flex-1 flex-col rounded-lg border border-[#26272E] bg-[#101014] p-6 transition-all hover:-translate-y-1 hover:border-[#3A3D46] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:p-7"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span :class="['flex h-14 w-14 shrink-0 items-center justify-center rounded-lg', f.accent]"> <span :class="['flex h-12 w-12 shrink-0 items-center justify-center rounded-lg', f.accent]">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" /> <path :d="f.icon" />
</svg> </svg>
</span> </span>
<div class="text-2xl font-semibold">{{ f.title }}</div> <div class="text-xl font-semibold">{{ f.title }}</div>
</div> </div>
<p class="mt-4 max-w-sm text-sm leading-6 text-[#A8AFB8]">{{ f.desc }}</p> <p class="mt-3 text-[13px] leading-5 text-[#A8AFB8]">{{ f.desc }}</p>
<div class="mt-auto flex items-center justify-end gap-1.5 pt-5 text-sm font-medium text-blue-600"> <div class="mt-auto flex items-center justify-end gap-1.5 pt-4 text-sm font-medium text-blue-600">
进入 进入
<svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg> <svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</div> </div>
</RouterLink> </RouterLink>
</div> </div>
<MarketOverview />
<MarketSyncBar />
<LimitBoard />
</div> </div>
</template> </template>

View File

@@ -22,6 +22,7 @@ const code = computed(() => String(route.params.code ?? ''));
const detail = ref<IndexDetail | null>(null); const detail = ref<IndexDetail | null>(null);
const weights = ref<IndexWeights | null>(null); const weights = ref<IndexWeights | null>(null);
const weightsError = ref(''); const weightsError = ref('');
let weightsToken = 0;
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
@@ -40,11 +41,13 @@ async function loadDetail(c: string) {
} finally { } finally {
loading.value = false; loading.value = false;
} }
// 成分权重仅国内指数有数据,且独立加载(失败不阻塞页面) // 成分权重仅国内指数有数据,且独立加载(失败不阻塞页面)
// 带 token 防乱序:快速切换指数时旧请求晚返回不能覆盖新指数的权重表
if (detail.value?.region === 'cn') { if (detail.value?.region === 'cn') {
const wToken = ++weightsToken;
getIndexWeights(c, 50) getIndexWeights(c, 50)
.then((w) => { weights.value = w; }) .then((w) => { if (wToken === weightsToken) weights.value = w; })
.catch((e) => { weightsError.value = e instanceof Error ? e.message : '获取成分权重失败'; }); .catch((e) => { if (wToken === weightsToken) weightsError.value = e instanceof Error ? e.message : '获取成分权重失败'; });
} }
} }
@@ -63,6 +66,7 @@ const kError = ref<string | null>(null);
const lastDate = ref(''); const lastDate = ref('');
let chart: Chart | null = null; let chart: Chart | null = null;
let loadToken = 0; let loadToken = 0;
let kCtrl: AbortController | null = null;
let lastCandles: Candle[] | null = null; let lastCandles: Candle[] | null = null;
function rebuild(candles: Candle[]) { function rebuild(candles: Candle[]) {
@@ -94,16 +98,21 @@ function rebuild(candles: Candle[]) {
async function loadCandles(c: string, tf: Timeframe) { async function loadCandles(c: string, tf: Timeframe) {
const token = ++loadToken; const token = ++loadToken;
kCtrl?.abort(); // 快速切指数/切周期时取消在途请求
const ctrl = new AbortController();
kCtrl = ctrl;
kLoading.value = true; kLoading.value = true;
kError.value = null; kError.value = null;
try { try {
const candles = await getAnyIndexCandles(c, tf); const candles = await getAnyIndexCandles(c, tf, { signal: ctrl.signal });
if (token !== loadToken) return; if (token !== loadToken) return;
lastCandles = candles; lastCandles = candles;
rebuild(candles); rebuild(candles);
lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : ''; lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : '';
} catch (e) { } catch (e) {
if (token === loadToken) kError.value = e instanceof Error ? e.message : '获取指数K线失败'; if (token === loadToken && !(e instanceof Error && e.name === 'AbortError')) {
kError.value = e instanceof Error ? e.message : '获取指数K线失败';
}
} finally { } finally {
if (token === loadToken) kLoading.value = false; if (token === loadToken) kLoading.value = false;
} }

View File

@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute } from 'vue-router';
import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client'; import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client';
import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types'; import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types';
import { useQuerySync } from '@/composables/useQuerySync';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue'; import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ---------- // ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute(); const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined { function qStr(key: string): string | undefined {
const v = route.query[key]; const v = route.query[key];
@@ -60,10 +60,14 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
// ---------- 加载(搜索防抖) ---------- // ---------- 加载(搜索防抖) ----------
let fetchToken = 0; let fetchToken = 0;
let fetchCtrl: AbortController | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined; let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() { async function load() {
const token = ++fetchToken; const token = ++fetchToken;
fetchCtrl?.abort();
const ctrl = new AbortController();
fetchCtrl = ctrl;
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@@ -78,13 +82,17 @@ async function load() {
order: order.value, order: order.value,
limit: pageSize, limit: pageSize,
offset: (page.value - 1) * pageSize, offset: (page.value - 1) * pageSize,
signal: ctrl.signal,
}); });
if (token === fetchToken) { if (token === fetchToken) {
items.value = res.items; items.value = res.items;
total.value = res.total; total.value = res.total;
} }
} catch (e) { } catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败'; // AbortError = 被更新的请求取消静默token 检查通常已挡住,这里双保险)
if (token === fetchToken && !(e instanceof Error && e.name === 'AbortError')) {
error.value = e instanceof Error ? e.message : '加载失败';
}
} finally { } finally {
if (token === fetchToken) loading.value = false; if (token === fetchToken) loading.value = false;
} }
@@ -191,47 +199,34 @@ function onWatchedChange() {
load(); load();
} }
// ---------- 路由同步:状态 → ?q/&market/…replace 不产生历史记录) ---------- // ---------- 路由同步:状态 → ?q/&market/…(骨架在 useQuerySyncreplace 不产生历史记录) ----------
function buildQuery(): Record<string, string> { const { syncRoute } = useQuerySync({
const q: Record<string, string> = {}; sources: [search, market, industry, area, sort, order, page],
if (search.value.trim()) q.q = search.value.trim(); buildQuery: () => {
if (market.value !== '全部') q.market = market.value; const q: Record<string, string> = {};
if (industry.value) q.industry = industry.value; if (search.value.trim()) q.q = search.value.trim();
if (area.value) q.area = area.value; if (market.value !== '全部') q.market = market.value;
if (sort.value !== 'symbol') q.sort = sort.value; if (industry.value) q.industry = industry.value;
if (order.value !== 'asc') q.order = order.value; if (area.value) q.area = area.value;
if (page.value > 1) q.page = String(page.value); if (sort.value !== 'symbol') q.sort = sort.value;
if (previewCode.value) q.code = previewCode.value; if (order.value !== 'asc') q.order = order.value;
return q; if (page.value > 1) q.page = String(page.value);
} if (previewCode.value) q.code = previewCode.value;
return q;
let selfNav = 0; // 自己发起的导航在途数量:其 route 变化不回灌状态(防输入被旧 URL 覆盖) },
function syncRoute(push = false) { // 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
const query = buildQuery(); applyQuery: (q) => {
// 与当前 URL 一致就跳过,避免 state→route→state 回声 search.value = q.q ?? '';
if (JSON.stringify(query) === JSON.stringify(route.query)) return; market.value = MARKETS.includes(q.market ?? '') ? (q.market as typeof MARKETS[number]) : '全部';
selfNav++; industry.value = q.industry ?? '';
const done = () => { selfNav--; }; area.value = q.area ?? '';
void (push ? router.push({ query }) : router.replace({ query })).then(done, done); const p = parseInt(q.page ?? '', 10);
} page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = q.sort ?? 'symbol';
// 列表状态变化(含搜索防抖外的输入)随手回写 URL翻页/筛选也带着当前 ?code sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'symbol';
watch([search, market, industry, area, sort, order, page], () => syncRoute()); order.value = q.order === 'desc' ? 'desc' : 'asc';
previewCode.value = q.code || null;
// 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态 },
watch(() => route.query, (q) => {
if (selfNav > 0) return;
const qOf = (k: string) => (typeof q[k] === 'string' ? (q[k] as string) : '');
search.value = qOf('q');
market.value = MARKETS.includes(qOf('market')) ? qOf('market') : '全部';
industry.value = qOf('industry');
area.value = qOf('area');
const p = parseInt(qOf('page'), 10);
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = qOf('sort');
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'symbol';
order.value = qOf('order') === 'desc' ? 'desc' : 'asc';
previewCode.value = qOf('code') || null;
}); });
// ---------- 详情浮层开关(写入 ?code= ---------- // ---------- 详情浮层开关(写入 ?code= ----------

View File

@@ -1,62 +0,0 @@
$ vite
VITE v6.4.3 ready in 1749 ms
➜ Local: http://localhost:5173/
 ➜ Network: use --host to expose
18:31:29 [vite] (client) page reload src/api/types.ts
18:31:29 [vite] (client) page reload src/api/client.ts
18:31:31 [vite] (client) page reload src/api/client.ts
18:31:45 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
18:31:48 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
19:07:53 [vite] (client) page reload src/api/types.ts
19:07:58 [vite] (client) page reload src/api/client.ts
19:08:04 [vite] (client) page reload src/api/client.ts
19:08:48 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
19:08:54 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
19:09:01 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
19:09:07 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
19:09:11 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
19:11:04 [vite] (client) page reload src/router.ts
19:29:33 [vite] (client) hmr update /src/views/IndexDetailView.vue, /src/style.css
19:29:35 [vite] (client) hmr update /src/views/IndexDetailView.vue, /src/style.css
01:50:18 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
01:50:23 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\MarketOverview.vue, /src/style.css
02:16:32 [vite] (client) page reload src/api/types.ts
02:16:33 [vite] (client) page reload src/api/types.ts
02:16:36 [vite] (client) page reload src/api/client.ts
02:16:46 [vite] (client) page reload src/api/client.ts
02:16:47 [vite] (client) page reload src/stores/settings.ts
02:16:48 [vite] (client) page reload src/stores/settings.ts
02:17:18 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:20 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:40 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:41 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:42 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:43 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:43 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:17:44 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css
02:18:28 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:30 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:35 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:49 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:55 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:57 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:18:57 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:19:09 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:19:09 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
02:24:43 [vite] http proxy error: /api/auth/me
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
07:54:29 [vite] (client) page reload src/api/types.ts
07:54:32 [vite] (client) page reload src/api/client.ts
07:54:39 [vite] (client) page reload src/api/client.ts
07:55:37 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
07:55:38 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css
08:25:59 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ReferencePanel.vue, /src/style.css
12:48:22 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ReferencePanel.vue, /src/style.css
12:48:23 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ReferencePanel.vue, /src/style.css
12:56:52 [vite] (client) hmr update /src/style.css, /@fs/D:\Project\stock\frontend\src\components\ReferencePanel.vue, /@fs/D:\Project\stock\frontend\src\components\ConditionChips.vue
12:56:59 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ReferencePanel.vue, /src/style.css
13:30:58 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\StockDetailOverlay.vue, /src/style.css