看股功能更新

This commit is contained in:
2026-08-16 00:05:26 +08:00
parent 9cce670b74
commit fc86fe0674
28 changed files with 3823 additions and 96 deletions

View File

@@ -3,6 +3,9 @@ TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
DATA_ADJUST=qfq
DATA_DEFAULT_START=20200101
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
# ---- LLM智能选股智谱 GLMOpenAI 兼容协议)----
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyyid.secret
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

View File

@@ -38,3 +38,9 @@ LLM_MODEL=glm-5.2
# TRANSFER_FEE_RATE=0.00001 # 过户费 0.001%,沪深双边
# COMMISSION_RATE=0.0001 # 佣金 万1
# COMMISSION_MIN=5.0 # 最低 5 元
# ---- Redis 读缓存(可选;股票列表/筛选项提速)----
# 留空 = 不缓存,直查数据库;连接失败自动降级,不影响接口可用性
# REDIS_URL=redis://default:password@127.0.0.1:6379
# STOCKS_CACHE_TTL=300 # 股票列表缓存秒数
# FACETS_CACHE_TTL=3600 # 行业/地域筛选项缓存秒数

View File

@@ -0,0 +1,36 @@
"""add adj_factor table (复权因子底座)
Revision ID: 20260815_01
Revises: 208b0c5d302a
Create Date: 2026-08-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260815_01"
down_revision: Union[str, Sequence[str], None] = "208b0c5d302a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"adj_factor",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trade_date", sa.DateTime(), nullable=False),
sa.Column("ts_code", sa.String(length=12), nullable=False),
sa.Column("adj_factor", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("ts_code", "trade_date", name="uq_adj_code_date"),
)
op.create_index("ix_adj_factor_trade_date", "adj_factor", ["trade_date"])
op.create_index("ix_adj_factor_ts_code", "adj_factor", ["ts_code"])
def downgrade() -> None:
op.drop_index("ix_adj_factor_ts_code", table_name="adj_factor")
op.drop_index("ix_adj_factor_trade_date", table_name="adj_factor")
op.drop_table("adj_factor")

View File

@@ -0,0 +1,69 @@
"""user data tables: preferences / watchlist / screener queries
Revision ID: 20260815_02
Revises: 20260815_01
Create Date: 2026-08-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260815_02"
down_revision: Union[str, Sequence[str], None] = "20260815_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_preferences",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("value_json", sa.Text(), nullable=False, server_default="null"),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "key", name="uq_user_pref_key"),
)
op.create_index("ix_user_preferences_user_id", "user_preferences", ["user_id"])
op.create_table(
"watchlist_items",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("ts_code", sa.String(length=12), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "ts_code", name="uq_watch_user_code"),
)
op.create_index("ix_watchlist_items_user_id", "watchlist_items", ["user_id"])
op.create_index("ix_watchlist_items_ts_code", "watchlist_items", ["ts_code"])
op.create_table(
"screener_queries",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("text", sa.String(length=500), nullable=False),
sa.Column("conditions_json", sa.Text(), nullable=True),
sa.Column("hit_count", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_screener_queries_user_id", "screener_queries", ["user_id"])
op.create_index("ix_screener_queries_created_at", "screener_queries", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_screener_queries_created_at", table_name="screener_queries")
op.drop_index("ix_screener_queries_user_id", table_name="screener_queries")
op.drop_table("screener_queries")
op.drop_index("ix_watchlist_items_ts_code", table_name="watchlist_items")
op.drop_index("ix_watchlist_items_user_id", table_name="watchlist_items")
op.drop_table("watchlist_items")
op.drop_index("ix_user_preferences_user_id", table_name="user_preferences")
op.drop_table("user_preferences")

View File

@@ -0,0 +1,30 @@
"""candles add amount/turnover columns
Revision ID: 20260815_03
Revises: 20260815_02
Create Date: 2026-08-15
- amount 成交额TDX .day 原生 float32/ Tushare daily amount 千元×1000
- turnover 换手率(%Tushare daily_basic.turnover_rate2000 年起)
均为可空列——历史回补前为 NULL前端 tooltip 显示 ""
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260815_03"
down_revision: Union[str, Sequence[str], None] = "20260815_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("candles", sa.Column("amount", sa.Float(), nullable=True))
op.add_column("candles", sa.Column("turnover", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("candles", "turnover")
op.drop_column("candles", "amount")

View File

@@ -0,0 +1,51 @@
"""user_trades: 交割单导入的实盘成交流水K线买卖点数据源
Revision ID: 20260815_04
Revises: 20260815_03
Create Date: 2026-08-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260815_04"
down_revision: Union[str, Sequence[str], None] = "20260815_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_trades",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("ts_code", sa.String(length=12), nullable=False),
sa.Column("code", sa.String(length=10), nullable=False),
sa.Column("name", sa.String(length=32), nullable=True),
sa.Column("trade_date", sa.Date(), nullable=False),
sa.Column("direction", sa.String(length=4), nullable=False),
sa.Column("price", sa.Float(), nullable=True),
sa.Column("qty", sa.Float(), nullable=False),
sa.Column("amount", sa.Float(), nullable=True),
sa.Column("fee", sa.Float(), nullable=False, server_default="0"),
sa.Column("raw_json", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id", "trade_date", "ts_code", "direction", "price", "qty",
name="uq_user_trade_dedup",
),
)
op.create_index("ix_user_trades_user_id", "user_trades", ["user_id"])
op.create_index("ix_user_trades_ts_code", "user_trades", ["ts_code"])
op.create_index("ix_user_trades_trade_date", "user_trades", ["trade_date"])
def downgrade() -> None:
op.drop_index("ix_user_trades_trade_date", table_name="user_trades")
op.drop_index("ix_user_trades_ts_code", table_name="user_trades")
op.drop_index("ix_user_trades_user_id", table_name="user_trades")
op.drop_table("user_trades")

View File

@@ -15,11 +15,13 @@ import json
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, text
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import TextClause
from .backtest.engine import BacktestConfig, run_backtest
from . import cache
from .auth import require_user
from .backtest.events import EventEngineError, run_event_backtest
from .backtest.strategies import build_strategy
@@ -30,6 +32,7 @@ from .data.symbols import plain_code
from .db import get_session
from .domain import Bar
from . import indicators as ind
from .trades import parse_statement
from .models import (
AdjFactor,
BacktestRun,
@@ -38,6 +41,7 @@ from .models import (
ScreenerQuery,
StockBasic,
UserPreference,
UserTrade,
WatchlistItem,
)
from .schemas import (
@@ -66,6 +70,9 @@ from .schemas import (
FacetItemOut,
SyncRequest,
SyncResponse,
TradesClearResponse,
TradesImportResponse,
UserTradeOut,
WatchlistOp,
)
from .screener import engine, market_sync
@@ -163,37 +170,63 @@ async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_sessio
# ---------- 股票列表(全市场浏览) ----------
_STOCKS_SQL = text("""
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
c.close AS close, p.close AS prev_close, c.ts AS last_ts, cnt.n AS bar_count,
CASE WHEN c.close IS NOT NULL AND p.close IS NOT NULL AND p.close <> 0
THEN round(((c.close / p.close - 1) * 100)::numeric, 2) END AS pct_chg,
(w.id IS NOT NULL) AS watched
FROM stock_basic sb
# 过滤/排序/分页在 stock_basic+watchlist+daily_snapshot 上完成(快照按最新交易日
# 走唯一索引 join便宜再对「本页」≤limit 只股票补最新价/昨收LATERAL 扫
# candles——旧写法对全市场 ~5000 只逐个算,每页都白算 50 倍的行情量。
# 排序列白名单键→CTE 内表达式order_by 由白名单拼接进模板,不接收用户原文。
_STOCKS_SORTS = {
"symbol": "sb.symbol",
"total_mv": "snap.total_mv",
"circ_mv": "snap.circ_mv",
"pe_ttm": "snap.pe_ttm",
"pb": "snap.pb",
"turnover_rate": "snap.turnover_rate",
}
_STOCKS_SQL_TMPL = """
WITH page AS (
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
(w.id IS NOT NULL) AS watched,
snap.turnover_rate, snap.pe_ttm, snap.pb, snap.total_mv, snap.circ_mv
FROM stock_basic sb
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
LEFT JOIN daily_snapshot snap ON snap.ts_code = sb.ts_code
AND snap.trade_date = (SELECT max(trade_date) FROM daily_snapshot)
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
ORDER BY {order_by}
LIMIT :limit OFFSET :offset
)
SELECT p.ts_code, p.symbol, p.name, p.industry, p.market, p.watched,
c.close AS close, prev.close AS prev_close, c.ts AS last_ts,
CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0
THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg,
p.turnover_rate, p.pe_ttm, p.pb,
round((p.total_mv / 10000.0)::numeric, 2) AS total_mv,
round((p.circ_mv / 10000.0)::numeric, 2) AS circ_mv
FROM page p
LEFT JOIN LATERAL (
SELECT close, ts FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d'
WHERE symbol = p.symbol AND timeframe = '1d'
ORDER BY ts DESC LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT close FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d' AND ts < c.ts
WHERE symbol = p.symbol AND timeframe = '1d' AND ts < c.ts
ORDER BY ts DESC LIMIT 1
) p ON c.ts IS NOT NULL
LEFT JOIN LATERAL (
SELECT count(*) AS n FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d'
) cnt ON true
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
ORDER BY w.id DESC NULLS LAST, sb.symbol
LIMIT :limit OFFSET :offset
""")
) prev ON c.ts IS NOT NULL
"""
def _stocks_sql(sort: str, order: str) -> TextClause:
col = _STOCKS_SORTS.get(sort, _STOCKS_SORTS["symbol"])
direction = "DESC" if order == "desc" else "ASC"
nulls = " NULLS LAST" if col != "sb.symbol" else "" # 快照缺失/亏损无 PE 的排最后
return text(_STOCKS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
_STOCKS_COUNT_SQL = text("""
SELECT count(*) FROM stock_basic sb
@@ -214,16 +247,32 @@ async def list_stocks(
industry: str = "",
area: str = "",
watched_only: bool = False,
sort: str = "symbol",
order: str = "asc",
limit: int = 100,
offset: int = 0,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> StockListResponse:
"""全市场股票列表stock_basic 基本信息 + candles 最新行情(本地缓存,无缓存则行情列为空)。
自选股watchlist_items排最前watched_only=true 只看自选。"""
"""全市场股票列表stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标
(换手率/PE-TTM/PB/市值,无快照则这些列为空)。
watched_only=true 只看自选(自选有独立的「自选」分类入口,列表不再把自选排最前)。
sort ∈ {symbol,total_mv,circ_mv,pe_ttm,pb,turnover_rate}(白名单,其他值回落 symbol
order ∈ asc/desc快照列排序时缺失值无快照/亏损无 PE恒排末尾。
Redis 缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total自选增删即时失效。"""
search = search.strip()
sort = sort if sort in _STOCKS_SORTS else "symbol"
order = "desc" if order.lower() == "desc" else "asc"
limit = max(1, min(limit, 500))
offset = max(0, offset)
key = (
f"stocks:u{user.id}"
f":v{await cache.get_version(f'watchlist:{user.id}')}"
f":{cache.digest(search, market, industry, area, watched_only, sort, order, limit, offset)}"
)
cached = await cache.cache_get(key)
if cached is not None:
return StockListResponse(**cached)
params = {
"search": search,
"psearch": f"%{search}%",
@@ -236,13 +285,18 @@ async def list_stocks(
"offset": offset,
}
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
rows = (await session.execute(_STOCKS_SQL, params)).mappings().all()
return StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
rows = (await session.execute(_stocks_sql(sort, order), params)).mappings().all()
resp = StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
await cache.cache_set(key, resp.model_dump(mode="json"), settings.stocks_cache_ttl)
return resp
@router.get("/stocks/facets", response_model=StockFacetsResponse)
async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFacetsResponse:
"""看股页筛选项:行业 / 地域(含数量,按数量降序)。"""
"""看股页筛选项:行业 / 地域(含数量,按数量降序)。stock_basic 很少变,长缓存。"""
cached = await cache.cache_get("facets:stocks")
if cached is not None:
return StockFacetsResponse(**cached)
industries = (
await session.execute(text("""
SELECT industry AS name, count(*) AS n FROM stock_basic
@@ -257,10 +311,12 @@ async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFac
GROUP BY area ORDER BY n DESC
"""))
).mappings().all()
return StockFacetsResponse(
resp = StockFacetsResponse(
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas],
)
await cache.cache_set("facets:stocks", resp.model_dump(mode="json"), settings.facets_cache_ttl)
return resp
@router.post("/backtest", response_model=BacktestResponse)
@@ -551,6 +607,7 @@ async def add_watchlist(
if exists is None:
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
@@ -565,9 +622,125 @@ async def remove_watchlist(
{"u": user.id, "c": ts_code},
)
await session.commit()
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
return await get_watchlist(session=session, user=user)
# ---------- 交割单(个人实盘买卖点) ----------
def _trade_out(r: UserTrade) -> UserTradeOut:
return UserTradeOut(
id=r.id, ts_code=r.ts_code, name=r.name, trade_date=r.trade_date,
direction=r.direction, price=r.price, qty=r.qty, amount=r.amount, fee=r.fee,
)
@router.get("/trades", response_model=list[UserTradeOut])
async def list_trades(
ts_code: str | None = None,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[UserTradeOut]:
"""当前用户导入的实盘成交(可选 ts_code 过滤按日期升序K线买卖点数据源"""
q = (
select(UserTrade)
.where(UserTrade.user_id == user.id)
.order_by(UserTrade.trade_date, UserTrade.id)
)
if ts_code:
q = q.where(UserTrade.ts_code == ts_code)
rows = (await session.execute(q)).scalars().all()
return [_trade_out(r) for r in rows]
@router.post("/trades/import", response_model=TradesImportResponse)
async def import_trades(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesImportResponse:
"""上传券商交割单CSV/Excel/HTML 表格均可,自动识别列名),解析出买卖成交入库。
同一笔成交(同日同股同向同价同量)重复上传会跳过,重复导出幂等。
"""
data = await file.read()
if not data:
raise HTTPException(status_code=422, detail="文件是空的")
if len(data) > 20 * 1024 * 1024:
raise HTTPException(status_code=413, detail="文件超过 20MB请分时间段导出")
parsed = parse_statement(data, file.filename or "")
# 无证券代码列的导出(招商式):按证券名称反查 stock_basic 补 ts_code同名多码或查不到则弃行
unnamed = {t.name for t in parsed.trades if not t.ts_code and t.name}
if unnamed:
name_map: dict[str, str] = {}
for ts_code, name in (await session.execute(
select(StockBasic.ts_code, StockBasic.name).where(StockBasic.name.in_(unnamed))
)).all():
name_map[name] = "" if name in name_map else ts_code
for t in parsed.trades:
if not t.ts_code and t.name:
tc = name_map.get(t.name, "")
if tc:
t.ts_code, t.code = tc, tc.split(".")[0]
else:
parsed.skipped_bad.append(f"{t.trade_date} {t.name} 名称无法唯一对应代码,未入库")
def _key(t) -> tuple:
return (t.trade_date, t.ts_code, t.direction, None if t.price is None else round(t.price, 4), round(t.qty, 4))
# Python 侧去重兜底(唯一约束对 NULL price 不生效)
existing = {
(r.trade_date, r.ts_code, r.direction, None if r.price is None else round(r.price, 4), round(r.qty, 4))
for r in (
await session.execute(
select(UserTrade.trade_date, UserTrade.ts_code, UserTrade.direction, UserTrade.price, UserTrade.qty)
.where(UserTrade.user_id == user.id, UserTrade.ts_code.in_({t.ts_code for t in parsed.trades}))
)
).all()
}
inserted: list[UserTrade] = []
seen: set[tuple] = set()
skipped_dup = 0
for t in parsed.trades:
if not t.ts_code:
continue # 名称反查失败的行,已在 bad 里说明
k = _key(t)
if k in existing or k in seen:
skipped_dup += 1
continue
seen.add(k)
inserted.append(UserTrade(
user_id=user.id, ts_code=t.ts_code, code=t.code, name=t.name or None,
trade_date=t.trade_date, direction=t.direction, price=t.price,
qty=t.qty, amount=t.amount, fee=t.fee,
raw_json=json.dumps(t.raw, ensure_ascii=False, default=str),
))
if inserted:
session.add_all(inserted)
await session.commit()
return TradesImportResponse(
inserted=len(inserted),
skipped_dup=skipped_dup,
skipped_other=parsed.skipped_other,
stocks=len({t.ts_code for t in parsed.trades}),
bad=parsed.skipped_bad[:5],
sample=[_trade_out(r) for r in inserted[:5]],
)
@router.delete("/trades", response_model=TradesClearResponse)
async def clear_trades(
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> TradesClearResponse:
"""清空当前用户导入的全部成交(重新导入前用)。"""
res = await session.execute(delete(UserTrade).where(UserTrade.user_id == user.id))
await session.commit()
return TradesClearResponse(deleted=res.rowcount or 0)
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
async def screener_sync_start(
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)

View File

@@ -0,0 +1,265 @@
"""事件回测引擎:入场条件命中 -> 次日买入 -> 持有 N 日 -> 全市场汇总统计。
数据口径:
- 行情底座是 candlesTDX 全量导入,不复权),全历史可用;
- 指标计算用不复权价(与选股/看盘口径一致J<10、RSI<30 等阈值均为归一化或惯例值);
- 收益率用 adj_factor 校正ret = 出场价×f出 / 入场价×f入 - 1消除除权除息失真
因子缺失的股退化为不复权收益(新股/缺因子,样本中占少数)。
信号语义:与选股引擎一致——每条条件在信号日 d 为终点、lookback 窗口内
match=all(连续满足)/any(曾经满足),多条件之间取 AND。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
import numpy as np
import pandas as pd
from sqlalchemy import and_, func, not_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AdjFactor, Candle, StockBasic
from ..schemas import EventBacktestSpec
from ..screener.engine import (
FAMILIES,
_family_of,
_op_mask,
_params_for,
_resolve_params,
_series_for,
)
# 指标配热缓冲 bar 数MACD 等 EMA 类指标需要较长窗口才收敛)
BUFFER_BARS = 80
# 每批查询的股票数(全市场分块拉取,避免单条 SQL 过大)
BATCH_SIZE = 800
# 单次回测允许的最大样本数(超过则仅按日期取最近的,防内存失控)
MAX_TRADES = 200_000
class EventEngineError(RuntimeError):
"""事件回测可预期的业务错误(信息透传前端)。"""
def _signal_mask(g: pd.DataFrame, spec: EventBacktestSpec, cache: dict) -> pd.Series:
"""单股全序列信号掩码各条件rolling lookbackAND。"""
total = pd.Series(True, index=g.index)
for cond in spec.entry.indicator:
fam = _family_of(cond.indicator)
if len(g) < FAMILIES[fam].min_bars:
return pd.Series(False, index=g.index)
s = _series_for(g, cond.indicator, _params_for(cond.indicator, cond.params), cache)
if s is None:
return pd.Series(False, index=g.index)
if cond.value_indicator:
target = _series_for(g, cond.value_indicator,
_resolve_params(cond, cond.value_indicator), cache)
if target is None:
return pd.Series(False, index=g.index)
else:
target = pd.Series(cond.value, index=s.index)
m = _op_mask(s, target, cond).astype(int)
n = max(1, cond.lookback)
if n > 1:
rolled = m.rolling(n, min_periods=n).sum()
m = (rolled == n) if cond.match == "all" else (rolled > 0)
else:
m = m.astype(bool)
total = total & m.fillna(False).astype(bool)
return total
def _entry_exit_indices(sig_idx: int, spec: EventBacktestSpec, n: int) -> tuple[int, int] | None:
"""信号日索引 -> (入场索引, 出场索引)。前视/越界返回 None。"""
entry_i = sig_idx + 1 # 信号收盘后才动手:一律次日
exit_i = entry_i + spec.holding_days
if exit_i >= n:
return None
return entry_i, exit_i
def _price_at(row: pd.Series, timing: str) -> float:
return float(row["open"] if timing == "open" else row["close"])
def _stats_block(trades: list[dict]) -> dict:
"""样本集合 -> 汇总统计(空样本给零值)。"""
if not trades:
return {
"samples": 0, "stocks": 0,
"mean_pct": 0.0, "median_pct": 0.0, "win_rate": 0.0, "std_pct": 0.0,
"p10_pct": 0.0, "p25_pct": 0.0, "p75_pct": 0.0, "p90_pct": 0.0,
"max_pct": 0.0, "min_pct": 0.0, "by_year": [],
}
rets = np.array([t["ret_pct"] for t in trades], dtype=float)
by_year: list[dict] = []
df = pd.DataFrame(trades)
for year, grp in df.groupby(df["entry_date"].dt.year):
r = grp["ret_pct"].to_numpy()
by_year.append({
"year": int(year), "samples": int(len(r)),
"mean_pct": round(float(r.mean()), 3),
"median_pct": round(float(np.median(r)), 3),
"win_rate": round(float((r > 0).mean() * 100), 2),
})
by_year.sort(key=lambda x: x["year"])
return {
"samples": int(len(rets)),
"stocks": int(df["ts_code"].nunique()),
"mean_pct": round(float(rets.mean()), 3),
"median_pct": round(float(np.median(rets)), 3),
"win_rate": round(float((rets > 0).mean() * 100), 2),
"std_pct": round(float(rets.std(ddof=1)) if len(rets) > 1 else 0.0, 3),
"p10_pct": round(float(np.percentile(rets, 10)), 3),
"p25_pct": round(float(np.percentile(rets, 25)), 3),
"p75_pct": round(float(np.percentile(rets, 75)), 3),
"p90_pct": round(float(np.percentile(rets, 90)), 3),
"max_pct": round(float(rets.max()), 3),
"min_pct": round(float(rets.min()), 3),
"by_year": by_year,
}
async def run_event_backtest(
session: AsyncSession,
spec: EventBacktestSpec,
ts_code: str | None = None,
start: date | None = None,
end: date | None = None,
) -> dict:
"""主入口:返回 {spec, universe, start, end, stats, trades(sample), total}。"""
entry = spec.entry
if not entry.indicator:
raise EventEngineError("入场条件必须包含技术指标条件(如 J<10、RSI<30")
# 时间窗默认最近一年end 以 candles 最大日期为准
end_dt = end
if end_dt is None:
end_dt = (await session.scalar(select(func.max(Candle.ts)))) or date.today()
if isinstance(end_dt, datetime):
end_dt = end_dt.date()
start_dt = start or (end_dt - timedelta(days=365))
if start_dt >= end_dt:
raise EventEngineError("回测起始日期必须早于结束日期")
needed = _max_needed_bars_safe(entry) + BUFFER_BARS
buffer_start = start_dt - timedelta(days=int(needed * 1.7)) # 交易日->日历日近似
# 股票池ts_code+symbol 映射candles 按 symbol 存)
name_map: dict[str, str] = {}
if ts_code:
rows = (await session.execute(
select(StockBasic.ts_code, StockBasic.symbol, StockBasic.name)
.where(StockBasic.ts_code == ts_code)
)).all()
if not rows:
raise EventEngineError(f"未知股票代码: {ts_code}")
universe = [(r[0], r[1]) for r in rows]
name_map = {r[0]: r[2] for r in rows}
else:
stmt = select(StockBasic.ts_code, StockBasic.symbol, StockBasic.name).where(
StockBasic.list_status == "L"
)
if entry.exclude_st:
stmt = stmt.where(not_(or_(StockBasic.name.like("%ST%"), StockBasic.name.like("%退%"))))
if entry.exclude_bj:
stmt = stmt.where(not_(StockBasic.ts_code.like("%.BJ")))
rows = (await session.execute(stmt)).all()
universe = [(r[0], r[1]) for r in rows]
name_map = {r[0]: r[2] for r in rows}
start_ts = datetime(start_dt.year, start_dt.month, start_dt.day)
end_ts = datetime(end_dt.year, end_dt.month, end_dt.day, 23, 59, 59)
buffer_ts = datetime(buffer_start.year, buffer_start.month, buffer_start.day)
trades: list[dict] = []
for i in range(0, len(universe), BATCH_SIZE):
batch = universe[i : i + BATCH_SIZE]
symbols = [sym for _, sym in batch]
code_by_symbol = {sym: code for code, sym in batch}
candle_rows = (await session.execute(
select(Candle.symbol, Candle.ts, Candle.open, Candle.high,
Candle.low, Candle.close)
.where(and_(Candle.timeframe == "1d",
Candle.symbol.in_(symbols),
Candle.ts >= buffer_ts, Candle.ts <= end_ts))
.order_by(Candle.symbol, Candle.ts)
)).all()
if not candle_rows:
continue
codes = {code_by_symbol[s] for s in symbols}
adj_rows = (await session.execute(
select(AdjFactor.ts_code, AdjFactor.trade_date, AdjFactor.adj_factor)
.where(and_(AdjFactor.ts_code.in_(codes),
AdjFactor.trade_date >= buffer_ts, AdjFactor.trade_date <= end_ts))
)).all()
f_map = {(r[0], r[1].date()): float(r[2]) for r in adj_rows if r[2]}
bars = pd.DataFrame(
candle_rows, columns=["symbol", "ts", "open", "high", "low", "close"]
)
for symbol, g in bars.groupby("symbol", sort=False):
if len(g) < 30:
continue
g = g.reset_index(drop=True)
ts_code_l = code_by_symbol[symbol]
cache: dict = {"_families": set()}
mask = _signal_mask(g, spec, cache)
if not mask.any():
continue
for sig_i in np.flatnonzero(mask.to_numpy()):
ts_sig = g.at[sig_i, "ts"]
# 信号必须落在回测窗口内buffer 区只用于指标配热)
if ts_sig < start_ts:
continue
ie = _entry_exit_indices(int(sig_i), spec, len(g))
if ie is None:
continue
entry_i, exit_i = ie
e_row, x_row = g.iloc[entry_i], g.iloc[exit_i]
e_price = _price_at(e_row, "open" if spec.entry_timing == "next_open" else "close")
x_price = _price_at(x_row, "open" if spec.exit_timing == "open" else "close")
if not e_price or not x_price:
continue
f_in = f_map.get((ts_code_l, e_row["ts"].date()), 1.0)
f_out = f_map.get((ts_code_l, x_row["ts"].date()), 1.0)
ret_pct = (x_price * f_out) / (e_price * f_in) * 100 - 100
trades.append({
"ts_code": ts_code_l,
"name": name_map.get(ts_code_l),
"entry_date": e_row["ts"], "entry_price": round(e_price, 3),
"exit_date": x_row["ts"], "exit_price": round(x_price, 3),
"ret_pct": round(float(ret_pct), 3),
})
if len(trades) >= MAX_TRADES:
break
if len(trades) >= MAX_TRADES:
break
if len(trades) >= MAX_TRADES:
break
stats = _stats_block(trades)
# 明细样本:最好 100 + 最差 100其余统计已覆盖
trades_sorted = sorted(trades, key=lambda t: t["ret_pct"], reverse=True)
sample = trades_sorted[:100] + (trades_sorted[-100:] if len(trades_sorted) > 100 else [])
return {
"spec": spec,
"universe": ts_code or "all",
"start": start_ts,
"end": end_ts,
"stats": stats,
"trades": sample,
"total": stats["samples"],
}
# ---------- 小工具 ----------
def _max_needed_bars_safe(conds) -> int:
"""指标配热所需最大 bar 数(同 screener.engine._max_needed_bars"""
need = 1
for c in conds.indicator:
need = max(need, FAMILIES[_family_of(c.indicator)].min_bars + c.lookback)
if c.value_indicator:
need = max(need, FAMILIES[_family_of(c.value_indicator)].min_bars + c.lookback)
return need

104
backend/app/cache.py Normal file
View File

@@ -0,0 +1,104 @@
"""Redis 读缓存(可选基础设施)。
- REDIS_URL 留空、连接失败或超时:所有操作静默退化为「无缓存」,接口照常直查数据库,
且本进程内禁用重试(避免每个请求都陪跑一次连接超时)。
- 失效策略TTL 自然过期 + 版本号INCR作废。自选股增删等写操作只 INCR 版本 key
旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。
- 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等);
K线/回测等口径敏感数据不走这里。
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
import redis.asyncio as aioredis
from .config import settings
_pool: aioredis.ConnectionPool | None = None
_disabled = False # 一次失败后本进程禁用Redis 属加速件,坏了不能拖慢接口)
def _client() -> aioredis.Redis | None:
global _pool, _disabled
if not settings.redis_url or _disabled:
return None
if _pool is None:
_pool = aioredis.ConnectionPool.from_url(
settings.redis_url,
decode_responses=True,
socket_connect_timeout=1.0,
socket_timeout=1.0,
health_check_interval=60,
max_connections=32,
)
return aioredis.Redis(connection_pool=_pool)
def _bail() -> None:
global _disabled
_disabled = True
def digest(*parts: Any) -> str:
"""参数指纹(拼接后 md5仅用于拼缓存 key非安全用途"""
raw = "\x1f".join(repr(p) for p in parts)
return hashlib.md5(raw.encode()).hexdigest() # noqa: S324
async def cache_get(key: str) -> Any | None:
c = _client()
if c is None:
return None
try:
raw = await c.get(key)
return json.loads(raw) if raw is not None else None
except Exception: # noqa: BLE001 —— 缓存层任何故障都不影响主流程
_bail()
return None
async def cache_set(key: str, value: Any, ttl: int) -> None:
c = _client()
if c is None:
return
try:
await c.set(key, json.dumps(value, ensure_ascii=False), ex=max(1, ttl))
except Exception: # noqa: BLE001
_bail()
async def get_version(name: str) -> int:
"""读版本号(缺省 0。版本号参与缓存 keyINCR 后旧 key 全部失效。"""
c = _client()
if c is None:
return 0
try:
v = await c.get(f"ver:{name}")
return int(v) if v is not None else 0
except Exception: # noqa: BLE001
_bail()
return 0
async def bump_version(name: str) -> None:
c = _client()
if c is None:
return
try:
await c.incr(f"ver:{name}")
except Exception: # noqa: BLE001
_bail()
async def aclose() -> None:
"""进程退出时释放连接池(由 main.lifespan 调用)。"""
global _pool
if _pool is not None:
try:
await _pool.disconnect()
except Exception: # noqa: BLE001
pass
_pool = None

View File

@@ -26,6 +26,11 @@ class Settings(BaseSettings):
data_adjust: str = "qfq" # 复权qfq 前复权 / hfq 后复权 / "" 不复权
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年)
# ---- Redis 读缓存(股票列表/筛选项等读多写少接口;留空 = 不缓存,直查数据库)----
redis_url: str = ""
stocks_cache_ttl: int = 300 # 股票列表缓存秒数(行情列允许最多滞后这么多秒)
facets_cache_ttl: int = 3600 # 行业/地域筛选项缓存秒数stock_basic 很少变)
# ---- LLM智能选股的自然语言解析DeepSeekOpenAI 兼容协议,可换任意兼容网关)----
llm_base_url: str = "https://api.deepseek.com"
llm_api_key: str = "" # 留空则智能选股不可用(其余功能不受影响)

View File

@@ -5,6 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from . import cache
from .api import router
from .auth_api import router as auth_router
from .config import settings
@@ -17,6 +18,7 @@ async def lifespan(app: FastAPI):
await conn.execute(text("SELECT 1"))
yield
await engine.dispose()
await cache.aclose() # 释放 Redis 连接池(未启用时是 no-op
app = FastAPI(

View File

@@ -7,9 +7,9 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
智能选股三表stock_basic / market_daily / daily_snapshot与回测 candles(qfq)
完全隔离:选股用未复权日线按 trade_date 全市场批量落地,避免污染回测复权缓存。
"""
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -173,6 +173,30 @@ class WatchlistItem(Base):
)
class UserTrade(Base):
"""交割单导入的实盘成交流水K线买卖点的数据源价格为券商成交原始价、不复权"""
__tablename__ = "user_trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
code: Mapped[str] = mapped_column(String(10)) # 6 位纯数字
name: Mapped[str | None] = mapped_column(String(32))
trade_date: Mapped[date] = mapped_column(Date, index=True) # 成交日期
direction: Mapped[str] = mapped_column(String(4)) # buy | sell
price: Mapped[float | None] = mapped_column(Float) # 成交价
qty: Mapped[float] = mapped_column(Float) # 股数
amount: Mapped[float | None] = mapped_column(Float) # 成交金额(元)
fee: Mapped[float] = mapped_column(Float, default=0.0) # 手续费合计(元)
raw_json: Mapped[str | None] = mapped_column(Text) # 原始行(审计/排错)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
# 重复上传同一份交割单幂等price 可空导致 PG 对 NULL 不去重,导入时另有 Python 侧兜底)
UniqueConstraint("user_id", "trade_date", "ts_code", "direction", "price", "qty", name="uq_user_trade_dedup"),
)
class ScreenerQuery(Base):
"""自然语言选股提问历史(文本 + 解析出的条件,便于一键重跑)。"""
__tablename__ = "screener_queries"

View File

@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
@@ -298,7 +298,11 @@ class StockListItemOut(BaseModel):
prev_close: float | None = None
pct_chg: float | None = None # 最新两根日线计算
last_ts: datetime | None = None
bar_count: int | None = None # 本地缓存日线条数
turnover_rate: float | None = None # 换手率 %daily_snapshot
pe_ttm: float | None = None # 市盈率 TTM
pb: float | None = None # 市净率
total_mv: float | None = None # 总市值(亿元)
circ_mv: float | None = None # 流通市值(亿元)
watched: bool = False # 是否自选(当前用户)
@@ -343,3 +347,29 @@ class ScreenerQueryOut(BaseModel):
class ScreenerQueryListResponse(BaseModel):
items: list[ScreenerQueryOut]
# ---------- 交割单(个人实盘买卖点) ----------
class UserTradeOut(BaseModel):
id: int
ts_code: str
name: str | None = None
trade_date: date # 成交日期ISO YYYY-MM-DD
direction: str # buy | sell
price: float | None = None # 成交价(券商原始价,不复权)
qty: float # 股数
amount: float | None = None
fee: float | None = None
class TradesImportResponse(BaseModel):
inserted: int # 新入库成交笔数
skipped_dup: int # 与库内完全一致(重复上传同文件)跳过
skipped_other: int # 非买卖行(转账/配号/利息等)
stocks: int # 涉及股票数
bad: list[str] = Field(default_factory=list) # 解析失败样例(前 5 条)
sample: list[UserTradeOut] = Field(default_factory=list) # 本次入库的前几笔(核对用)
class TradesClearResponse(BaseModel):
deleted: int

329
backend/app/trades.py Normal file
View File

@@ -0,0 +1,329 @@
"""交割单解析(券商导出的成交流水 → 结构化买卖记录)。
支持三类导出物(按内容嗅探,不信任扩展名):
- CSV/制表符文本utf-8-sig / gbk / gb18030 自动探测)
- Excel .xlsxopenpyxl很多券商导出的 .xls 实为 xlsx 或 HTML先按魔数分流
- HTML 表格(.xls 常见真身:<table><tr><td>
列名模糊匹配兼容通达信/恒生/同花顺系的命名差异;业务名称含「买入/卖出」
才入库,银行转账、配号、利息、红利等非交易行跳过并计数。
"""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from fastapi import HTTPException
@dataclass
class ParsedTrade:
trade_date: date
ts_code: str
code: str
name: str
direction: str # buy | sell
price: float | None
qty: float
amount: float | None
fee: float
raw: dict = field(default_factory=dict)
@dataclass
class ParseResult:
trades: list[ParsedTrade] = field(default_factory=list)
skipped_other: int = 0 # 非证券买卖行(转账/配号/利息等)
skipped_bad: list[str] = field(default_factory=list) # 解析失败样例(截断到前 5 条)
header_row_index: int = -1
columns: dict[str, str] = field(default_factory=dict) # 逻辑列 -> 实际列名
# ---------- 列名别名(归一化后做「包含」匹配,先命中的优先) ----------
COLUMN_ALIASES: dict[str, list[str]] = {
"date": ["成交日期", "交割日期", "交收日期", "交易日期", "过户日期", "发生日期", "清算日期", "日期"],
"op": ["业务名称", "业务摘要", "操作", "业务类型", "交易类型", "交易类别", "摘要", "方向", "买卖标志"],
"code": ["证券代码", "股票代码", "产品代码", "代码"],
"name": ["证券名称", "股票名称", "产品名称", "名称"],
"qty": ["成交数量", "发生数量", "委托数量", "成交股数", "数量"],
"price": ["成交价格", "成交均价", "成交价", "均价", "价格"],
"amount": ["成交金额", "成交清算金额", "清算金额", "发生金额", "资金发生数", "金额"],
"fee": ["手续费", "佣金", "印花税", "过户费", "其他费", "杂费", "规费"],
}
# 手续费类允许多列求和(手续费+印花税+过户费…),其余逻辑列取第一命中
_FEE_KEYS = ("手续费", "佣金", "印花税", "过户费", "其他费", "杂费", "规费")
def _norm_header(h: str) -> str:
"""列名归一化:去空白、去全角、去括号单位(如「成交数量(股)」)。"""
h = str(h).strip().replace(" ", "").replace(" ", "").replace(" ", "")
h = re.sub(r"[(【\[].*?[))】\]]", "", h)
return h
def _match_columns(header: list[str]) -> dict[str, str]:
"""表头 -> 逻辑列映射。返回 {逻辑列: 实际列名};费率类列全部收集到 fee(合并名)。"""
out: dict[str, str] = {}
fee_cols: list[str] = []
for h in header:
n = _norm_header(h)
if not n:
continue
for key, aliases in COLUMN_ALIASES.items():
if key == "fee":
if any(a in n for a in _FEE_KEYS):
fee_cols.append(h)
continue
if key in out:
continue
if any(a in n for a in aliases):
out[key] = h
break
# 「费用合计」列本身已含全部费用明细,取它即可,避免与手续费/印花税等列重复累加
total_col = next((h for h in header if "费用合计" in _norm_header(h)), None)
if total_col is not None:
out["fee"] = total_col
elif fee_cols:
out["fee"] = "\x00".join(fee_cols) # 多列合并存储,取值时拆开求和
return out
def _looks_like_header(row: list[str]) -> bool:
"""前 10 行里找表头≥3 个逻辑列可识别即认为是表头。"""
return len(_match_columns(row)) >= 3
def _to_float(v) -> float | None:
"""'1,234.50' / '(123.45)' / '--' / '' → float不可解析返回 None。"""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "").replace("", "")
if not s or s in {"--", "-", ""}:
return None
neg = s.startswith("(") and s.endswith(")")
if neg:
s = s[1:-1]
try:
f = float(s)
except ValueError:
return None
return -f if neg else f
def _to_date(v) -> date | None:
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
if isinstance(v, (int, float)) and not isinstance(v, bool) and 30000 < v < 60000:
# Excel 日期序列值1982~2064openpyxl 读无日期格式的单元格时会给出
from datetime import timedelta
return date(1899, 12, 30) + timedelta(days=int(v))
s = str(v).strip()
m = re.search(r"(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})", s)
if not m:
m2 = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", s)
if not m2:
return None
m = m2
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
return date(y, mo, d)
except ValueError:
return None
def _to_code_suffix(code: str) -> str:
"""6 位代码 → 交易所后缀60/68 沪00/30 深4/8/92 北交所)。"""
if code.startswith(("60", "68", "90")):
return ".SH"
if code.startswith(("00", "30", "20")):
return ".SZ"
return ".BJ"
def _direction(op: str) -> str | None:
s = str(op)
if "买入" in s or "buy" in s.lower() or "证券买" in s:
return "buy"
if "卖出" in s or "sell" in s.lower() or "证券卖" in s:
return "sell"
return None
def _parse_rows(rows: list[list[object]]) -> ParseResult:
"""已抽成二维表的行集 → ParseResult。rows[0] 应是表头(调用方已定位)。"""
res = ParseResult()
if not rows:
return res
header = [str(h) for h in rows[0]]
cols = _match_columns(header)
res.columns = {k: v for k, v in cols.items()}
res.header_row_index = 0
need = ("date", "qty")
if not all(k in cols for k in need) or not ("code" in cols or "name" in cols):
raise HTTPException(
status_code=422,
detail="识别不到交割单表头(需要 成交日期/证券代码或证券名称/成交数量 等列),"
"请确认导出的是「交割单/历史成交」文件",
)
idx = {h: i for i, h in enumerate(header)}
# 无「业务名称」列的导出(如部分招商证券格式):靠发生金额正负判方向(买入为负)。
# 仅当数据里确实存在负数金额才启用,避免「全正数」格式被误判。
def _amount_of(row: list[object]) -> float | None:
i = idx.get(cols["amount"])
return _to_float(row[i]) if i is not None and i < len(row) else None
sign_mode = "op" not in cols and "amount" in cols and any(
(_amount_of(row) or 0) < 0 for row in rows[1:] if any(str(c).strip() for c in row)
)
def cell(row: list[object], col: str):
i = idx.get(col)
return row[i] if i is not None and i < len(row) else None
for row in rows[1:]:
d = _to_date(cell(row, cols["date"]))
code = re.sub(r"\D", "", str(cell(row, cols["code"]) or "")) if "code" in cols else ""
raw_amount = _amount_of(row) if sign_mode else None
direction = (
_direction(str(cell(row, cols["op"]) or "")) if "op" in cols
else ("buy" if (raw_amount or 0) < 0 else "sell") if sign_mode
else None
)
name = str(cell(row, cols["name"]) or "").strip() if "name" in cols else ""
if d is None or (not code and not name) or direction is None:
# 无日期/无代码且无名称/非买卖业务(银行转账、配号、利息、红利等)
if any(str(c).strip() for c in row):
res.skipped_other += 1
continue
if len(code) > 6:
code = code[-6:] # 个别导出带市场前缀(如 1:600000 / sh600000
qty = abs(_to_float(cell(row, cols["qty"])) or 0)
if qty <= 0:
res.skipped_bad.append(f"{d} {code or name} 数量无效:{cell(row, cols['qty'])!r}")
continue
price = _to_float(cell(row, cols["price"])) if "price" in cols else None
amount = raw_amount if sign_mode else (_to_float(cell(row, cols["amount"])) if "amount" in cols else None)
if amount is not None:
amount = abs(amount)
fee = 0.0
if "fee" in cols:
for fc in cols["fee"].split("\x00"):
f = _to_float(cell(row, fc))
if f:
fee += abs(f)
# 无代码列招商式导出ts_code 留空,由 API 层按 name 反查 stock_basic
ts_code = code + _to_code_suffix(code) if code else ""
res.trades.append(ParsedTrade(
trade_date=d,
code=code,
ts_code=ts_code,
name=name,
direction=direction,
price=price,
qty=qty,
amount=amount,
fee=round(fee, 2),
raw={h: row[i] if i < len(row) else None for i, h in enumerate(header)},
))
res.skipped_bad = res.skipped_bad[:5]
return res
def _find_header(rows: list[list[object]]) -> int:
for i, row in enumerate(rows[:10]):
if _looks_like_header([str(c) for c in row]):
return i
return -1
# ---------- 输入格式分流 ----------
def _rows_from_csv(data: bytes) -> list[list[object]]:
"""逗号/制表符分隔文本。sniff 分隔符;跳过全空行。"""
text = None
for enc in ("utf-8-sig", "gbk", "gb18030"):
try:
text = data.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise HTTPException(status_code=422, detail="文件编码无法识别(支持 UTF-8 / GBK")
sample = text[:4096]
delim = "\t" if sample.count("\t") > sample.count(",") else ","
lines = [ln for ln in text.splitlines() if ln.strip()]
if not lines:
raise HTTPException(status_code=422, detail="文件是空的")
return [next(csv.reader([ln], delimiter=delim)) for ln in lines]
def _rows_from_xlsx(data: bytes) -> list[list[object]]:
from openpyxl import load_workbook
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception as e: # noqa: BLE001 - openpyxl 对损坏文件抛各种类型
raise HTTPException(status_code=422, detail=f"Excel 文件无法读取:{e}") from e
ws = wb.active
rows = [[c for c in row] for row in ws.iter_rows(values_only=True)]
wb.close()
return rows
_TD_RE = re.compile(r"<t[dh][^>]*>(.*?)</t[dh]>", re.IGNORECASE | re.DOTALL)
_TR_RE = re.compile(r"<tr[^>]*>(.*?)</tr>", re.IGNORECASE | re.DOTALL)
def _rows_from_html(data: bytes) -> list[list[object]]:
"""券商导出的 .xls 常是 HTML 表格。去掉标签实体后按 <tr>/<td> 切。"""
text = None
for enc in ("utf-8", "gbk", "gb18030"):
try:
text = data.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise HTTPException(status_code=422, detail="文件编码无法识别(支持 UTF-8 / GBK")
import html as html_mod
rows: list[list[object]] = []
for tr in _TR_RE.findall(text):
cells = [html_mod.unescape(re.sub(r"<[^>]+>", "", td)).strip() for td in _TD_RE.findall(tr)]
rows.append(cells)
if not rows:
raise HTTPException(status_code=422, detail="HTML 里没有表格数据")
return rows
def parse_statement(data: bytes, filename: str) -> ParseResult:
"""入口:按内容魔数/特征分流 → 定位表头 → 解析。"""
if not data:
raise HTTPException(status_code=422, detail="文件是空的")
head = data[:512].lstrip()
if head.startswith(b"PK"):
rows = _rows_from_xlsx(data)
elif head[:1] in (b"<",) or head.lower().startswith(b"\xef\xbb\xbf<"):
rows = _rows_from_html(data)
elif filename.lower().endswith((".xlsx", ".xls")) and not head.startswith((b"PK", b"<")):
# 扩展名是 Excel 但内容既非 xlsx 也非 HTML → 试试当文本
rows = _rows_from_csv(data)
else:
rows = _rows_from_csv(data)
# 去尾部全空行,定位表头(导出物常有标题行/账户信息行在前)
while rows and not any(str(c).strip() for c in rows[-1]):
rows.pop()
hi = _find_header(rows)
if hi < 0:
raise HTTPException(
status_code=422,
detail="找不到表头行(前 10 行内没有 成交日期/证券代码 等列名),请确认导出的是交割单",
)
return _parse_rows(rows[hi:])

View File

@@ -16,6 +16,9 @@ dependencies = [
"httpx>=0.28.1",
"argon2-cffi>=25.1.0",
"alembic>=1.19.1",
"redis>=8.1.0",
"python-multipart>=0.0.32",
"openpyxl>=3.1.5",
]
[tool.uv]

View File

@@ -0,0 +1,122 @@
"""全量回补历史复权因子adj_factor 表)。
用法(在 backend 目录下):
uv run python scripts/backfill_adj_factor.py # 从 candles 最早日期回补到今天
uv run python scripts/backfill_adj_factor.py --start 20180101
uv run python scripts/backfill_adj_factor.py --force # 已有日期也重拉
- 按交易日逐日拉取全市场因子pro.adj_factor(trade_date=...)),幂等可断点续跑;
- 交易日取自本地 trade_calendar缓存不到的区间自动刷新一次日历
- Tushare 每分钟限频由 _call_retry 自动等待 62s 重试。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import delete, func, insert, select
from app.db import async_session
from app.models import AdjFactor, Candle, TradeCalendar
from app.screener.market_sync import _call_retry, _get_pro, _norm_date, _parse_d
_INTERVAL_MSG = 20 # 每完成 N 个交易日打印一次进度
async def _calendar_dates(start: str, end: str) -> list[str]:
"""[start, end] 交易日(升序)。本地日历覆盖不足时直接拉宽范围日历并回写缓存。"""
async with async_session() as session:
all_cached = set((await session.execute(select(TradeCalendar.trade_date))).scalars().all())
cached = sorted(d for d in all_cached if start <= d <= end)
if cached and min(cached) <= start:
return cached
# 覆盖不到起点按需拉宽范围日历trade_cal 低积分限频 1 次/小时,失败沿用缓存)
pro = _get_pro()
try:
cal = await asyncio.to_thread(
_call_retry, pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1"
)
dates = sorted(cal["cal_date"].tolist())
except Exception as e: # noqa: BLE001
if not cached:
raise
print(f"交易日历拉取受限({str(e)[:100]}),沿用本地缓存")
return cached
fresh = [d for d in dates if d not in all_cached]
if fresh:
async with async_session() as session:
await session.execute(insert(TradeCalendar), [{"trade_date": d} for d in fresh])
await session.commit()
return dates
async def _existing_dates() -> set[str]:
async with async_session() as session:
res = await session.execute(select(func.distinct(AdjFactor.trade_date)))
return {_norm_date(r[0]) for r in res}
async def main(start: str, end: str, force: bool) -> None:
# 默认起点candles 最早日线(因子只需覆盖有 K 线的区间)
if start is None:
async with async_session() as session:
first = await session.scalar(select(func.min(Candle.ts)).where(Candle.timeframe == "1d"))
start = first.strftime("%Y%m%d") if first else "20050101"
if end is None:
end = datetime.now().strftime("%Y%m%d")
dates = await _calendar_dates(start, end)
have = set() if force else await _existing_dates()
todo = [d for d in dates if d not in have]
print(f"区间 {start}~{end}{len(dates)} 个交易日,待回补 {len(todo)} 个(已有 {len(dates) - len(todo)}")
if not todo:
return
pro = _get_pro()
done = 0
for d in todo:
time.sleep(0.15) # 轻微控频;分钟级限频由 _call_retry 自动等待重试
df = None
for attempt in range(5): # 网络抖动(超时/断连也重试_call_retry 只兜限频
try:
df = _call_retry(pro.adj_factor, trade_date=d) # noqa: 线性脚本直接同步调用
break
except Exception as e: # noqa: BLE001
wait = min(30 * (attempt + 1), 120)
print(f" {d} 拉取异常({str(e)[:80]}{wait}s 后重试 {attempt + 1}/5")
time.sleep(wait)
if df is None:
print(f" {d} 连续 5 次失败,跳过(断点续跑可补)")
continue
if df is None or df.empty:
print(f" {d} 无数据(非交易日或未生成),跳过")
continue
rows = [
{"trade_date": _parse_d(d), "ts_code": r["ts_code"], "adj_factor": float(r["adj_factor"])}
for _, r in df.iterrows()
]
async with async_session() as session:
dt = _parse_d(d)
await session.execute(delete(AdjFactor).where(AdjFactor.trade_date == dt))
await session.execute(insert(AdjFactor), rows)
await session.commit()
done += 1
if done % _INTERVAL_MSG == 0 or done == len(todo):
print(f" 进度 {done}/{len(todo)}{d}+{len(rows)} 行)")
print(f"回补完成:{done} 个交易日")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="全量回补历史复权因子")
ap.add_argument("--start", default=None, help="YYYYMMDD默认 candles 最早日期")
ap.add_argument("--end", default=None, help="YYYYMMDD默认今天")
ap.add_argument("--force", action="store_true", help="已有日期也重拉")
a = ap.parse_args()
asyncio.run(main(a.start, a.end, a.force))

View File

@@ -0,0 +1,162 @@
"""全量回补换手率candles.turnover单位 %)。
用法(在 backend 目录下):
uv run python scripts/backfill_turnover.py # 从 2000-01-01daily_basic 起点)回补到今天
uv run python scripts/backfill_turnover.py --start 20200101
uv run python scripts/backfill_turnover.py --force # 已回补的交易日也重拉
- 数据源Tushare daily_basic(trade_date=..., fields='ts_code,turnover_rate'),按日全市场;
- 幂等可断点续跑:某交易日 candles 已有非空 turnover 即跳过(--force 强制重做);
- 交易日取自本地 trade_calendar缓存覆盖不到起点时自动拉一次宽范围日历
- 每日一条 UPDATE ... FROM unnest(...) 批量写回,仅更新 turnover 列;
- Tushare 每分钟限频由 _call_retry 自动等待 62s 重试。
注意:与 import_tdx_day.py回填 amount 会整行 upsert串行运行避免同表行锁竞争。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.screener.market_sync import _call_retry, _get_pro
import asyncpg
def load_db_url() -> str:
"""与 import_tdx_day.py 相同的 .env -> libpq URL 解析(本地复制避免跨脚本导入)。"""
env = Path(__file__).resolve().parent.parent / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("DATABASE_URL=postgresql+asyncpg://"):
return "postgresql://" + line.split("://", 1)[1]
return "postgresql://postgres:postgres@localhost:5432/stock"
_DAILY_BASIC_FLOOR = "20000101" # daily_basic 最早覆盖 2000-01-04更早的交易日无换手数据
_INTERVAL_MSG = 20
async def _calendar_dates(conn: asyncpg.Connection, start: str, end: str) -> list[str]:
"""[start, end] 交易日(升序)。本地缓存覆盖不到起点时拉一次宽范围日历并回写。"""
cached = [r[0] for r in await conn.fetch(
"SELECT trade_date FROM trade_calendar WHERE trade_date >= $1 AND trade_date <= $2 "
"ORDER BY trade_date", start, end)]
if cached and cached[0] <= start:
return cached
pro = _get_pro()
try:
cal = await asyncio.to_thread(
_call_retry, pro.trade_cal, exchange="SSE", start_date=start, end_date=end, is_open="1"
)
dates = sorted(cal["cal_date"].tolist())
except Exception as e: # noqa: BLE001
if not cached:
raise
print(f"交易日历拉取受限({str(e)[:100]}),沿用本地缓存")
return cached
have = set(cached)
fresh = [d for d in dates if d not in have]
if fresh:
await conn.executemany(
"INSERT INTO trade_calendar (trade_date) VALUES ($1) ON CONFLICT DO NOTHING", [(d,) for d in fresh]
)
return dates
async def _day_status(conn: asyncpg.Connection, d: str) -> tuple[int, int]:
"""(已有换手的行数, 当日总行数)。无行情的日子 total=0 直接跳过。"""
row = await conn.fetchrow(
"SELECT count(*) FILTER (WHERE turnover IS NOT NULL) AS done, count(*) AS total "
"FROM candles WHERE timeframe = '1d' AND ts = $1::timestamp", datetime.strptime(d, "%Y%m%d")
)
return row["done"], row["total"]
async def main(start: str, end: str, force: bool) -> None:
conn = await asyncpg.connect(load_db_url())
try:
# 默认起点daily_basic 覆盖范围与 candles 最早日线的较大者(更早的日期拉了也是空)
if start is None:
first = await conn.fetchval(
"SELECT min(ts) FROM candles WHERE timeframe = '1d' AND symbol <> 'DEMO'")
start = max(first.strftime("%Y%m%d"), _DAILY_BASIC_FLOOR) if first else _DAILY_BASIC_FLOOR
if end is None:
end = datetime.now().strftime("%Y%m%d")
dates = await _calendar_dates(conn, start, end)
todo: list[str] = []
for d in dates:
if force:
done, total = await _day_status(conn, d)
if total:
todo.append(d)
continue
done, total = await _day_status(conn, d)
if total and done < total // 2: # 过半缺换手才重做(容忍个别股票无快照)
todo.append(d)
print(f"区间 {start}~{end}{len(dates)} 个交易日,待回补 {len(todo)}")
pro = _get_pro()
done = 0
t0 = time.time()
for d in todo:
time.sleep(0.15) # 轻微控频;分钟级限频由 _call_retry 自动等待重试
df = None
for attempt in range(5): # 网络抖动(超时/断连也重试_call_retry 只兜限频
try:
df = _call_retry(
pro.daily_basic, trade_date=d, fields="ts_code,trade_date,turnover_rate"
)
break
except Exception as e: # noqa: BLE001
wait = min(30 * (attempt + 1), 120)
print(f" {d} 拉取异常({str(e)[:80]}{wait}s 后重试 {attempt + 1}/5")
time.sleep(wait)
if df is None:
print(f" {d} 连续 5 次失败,跳过(断点续跑可补)")
continue
if df.empty:
continue
syms: list[str] = []
vals: list[float] = []
for _, r in df.iterrows():
tr = r["turnover_rate"]
if tr is None or tr != tr: # None / NaN
continue
syms.append(str(r["ts_code"]).split(".")[0])
vals.append(float(tr))
if not syms:
continue
n = await conn.execute(
"UPDATE candles AS c SET turnover = v.t "
"FROM unnest($1::text[], $2::float8[]) AS v(sym, t) "
"WHERE c.symbol = v.sym AND c.timeframe = '1d' AND c.ts = $3::timestamp",
syms, vals, datetime.strptime(d, "%Y%m%d"),
)
done += 1
if done % _INTERVAL_MSG == 0 or done == len(todo):
elapsed = time.time() - t0
eta = elapsed / done * (len(todo) - done) if done else 0
print(f" 进度 {done}/{len(todo)}{d}{len(syms)} 只,{n}"
f"{elapsed:.0f}s 已用,预计还需 {eta/60:.0f}m")
print(f"回补完成:{done} 个交易日")
finally:
await conn.close()
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="全量回补换手率 candles.turnover")
ap.add_argument("--start", default=None, help="YYYYMMDD默认 max(candles 最早, 20000101)")
ap.add_argument("--end", default=None, help="YYYYMMDD默认今天")
ap.add_argument("--force", action="store_true", help="已有换手的交易日也重拉")
a = ap.parse_args()
asyncio.run(main(a.start, a.end, a.force))

View File

@@ -0,0 +1,160 @@
"""通达信「沪深京日线数据完整包」全量导入 candles 表。
用法(在 backend 目录下):
uv run python scripts/import_tdx_day.py C:/Users/cirry/Downloads/hsjday [symbol ...]
# symbol 为可选的 6 位代码过滤(如 000001 002671只重导这些标的
uv run python scripts/import_tdx_day.py <目录> --no-clear
# --no-clear不清空任何行纯 upsert用于给已导入的底座回补 amount 成交额)
- 解析 vipdoc 的 .day 二进制文件(每条 32 字节):
日期(YYYYMMDD) 开 高 低 收×100 成交额(元, float32) 成交量(股) 保留
- 只导入 stock_basic 里登记的股票(自动排除指数/基金/可转债/回购);
sh000001(上证指数) 与 sz000001(平安银行) 这类代码冲突也由此化解。
- 价格为**不复权**:全量模式导入前清空已有的非 DEMO 行情;指定 symbol 过滤时
只清空这些标的(用于修复被复权口径污染的个别股票),其余不动。
- amount 为 TDX 原生 float32精度 ~6 位有效数字,展示用途足够;
ON CONFLICT 时仅更新 amount 列,不动 OHLCV/turnover避免与换手率回补互相干扰
- 写入用 asyncpg execute_many + ON CONFLICT DO UPDATE可重复执行幂等
"""
from __future__ import annotations
import argparse
import asyncio
import struct
import sys
import time
from pathlib import Path
import asyncpg
# .env 里的 DATABASE_URL 是 SQLAlchemy 格式asyncpg 需要 libpq 格式
DEFAULT_URL = "postgresql://postgres:postgres@localhost:5432/stock"
BATCH = 20_000 # 每批 upsert 行数
def load_db_url() -> str:
env = Path(__file__).resolve().parent.parent / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("DATABASE_URL=postgresql+asyncpg://"):
return "postgresql://" + line.split("://", 1)[1]
return DEFAULT_URL
def parse_day_file(path: Path) -> list[tuple[int, float, float, float, float, float, float]]:
"""解析单个 .day 文件 -> [(date, open, high, low, close, volume(股), amount(元)), ...]"""
raw = path.read_bytes()
unpack = struct.Struct("<IIIIIfII").unpack_from
out = []
for i in range(len(raw) // 32):
date, o, h, l, c, amount, vol, _reserved = unpack(raw, i * 32)
out.append((date, o / 100.0, h / 100.0, l / 100.0, c / 100.0, float(vol), float(amount)))
return out
async def main(root: Path, symbols: list[str] | None = None, no_clear: bool = False) -> None:
if not root.exists():
sys.exit(f"目录不存在: {root}")
conn = await asyncpg.connect(load_db_url())
try:
# 股票清单ts_code 形如 000001.SZ用于过滤指数/基金/转债
rows = await conn.fetch("SELECT ts_code, symbol FROM stock_basic WHERE list_status = 'L'")
by_exchange: dict[str, set[str]] = {"sh": set(), "sz": set(), "bj": set()}
for r in rows:
suffix = r["ts_code"].split(".")[-1].lower() # SH/SZ/BJ -> sh/sz/bj
if suffix in by_exchange:
by_exchange[suffix].add(r["symbol"])
print(f"stock_basic 在市股票: " + ", ".join(f"{k}={len(v)}" for k, v in by_exchange.items()))
files = sorted(root.glob("*/lday/*.day"))
print(f"发现 .day 文件: {len(files)}")
if no_clear:
print("--no-clear不清空任何行纯 upsert 回补 amount")
elif symbols:
# 清空旧行情(保留 DEMO 合成数据),避免 qfq/不复权混用;
# 带 symbol 过滤时只清空目标标的(修复个别被污染的股票,不动其余底座)
deleted = await conn.execute(
"DELETE FROM candles WHERE symbol = ANY($1)", symbols
)
print(f"清空目标标的 {symbols}: {deleted}")
keep = set(symbols)
files = [p for p in files if p.name[2:8] in keep]
print(f"过滤后待导入 .day 文件: {len(files)}")
else:
deleted = await conn.execute("DELETE FROM candles WHERE symbol <> 'DEMO'")
print(f"清空旧行情: {deleted}")
if no_clear:
# 回填模式:只写 amount不动 OHLCV/turnover底座已就位避免全表重写
upsert_sql = """
INSERT INTO candles (symbol, timeframe, ts, open, high, low, close, volume, amount)
VALUES ($1, '1d', to_timestamp($2::text, 'YYYYMMDD')::timestamp, $3, $4, $5, $6, $7, $8)
ON CONFLICT (symbol, timeframe, ts) DO UPDATE
SET amount = EXCLUDED.amount
"""
else:
upsert_sql = """
INSERT INTO candles (symbol, timeframe, ts, open, high, low, close, volume, amount)
VALUES ($1, '1d', to_timestamp($2::text, 'YYYYMMDD')::timestamp, $3, $4, $5, $6, $7, $8)
ON CONFLICT (symbol, timeframe, ts) DO UPDATE
SET open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
close = EXCLUDED.close, volume = EXCLUDED.volume, amount = EXCLUDED.amount
"""
t0 = time.time()
total_stocks = 0
skipped = 0
batch: list[tuple] = []
rows_done = 0
async def flush() -> None:
nonlocal batch, rows_done
if batch:
await conn.executemany(upsert_sql, batch)
rows_done += len(batch)
batch = []
for n, path in enumerate(files, 1):
market = path.name[:2].lower() # sh / sz / bj
code = path.name[2:8]
if code not in by_exchange.get(market, set()):
skipped += 1
continue
for date, o, h, l, c, v, amount in parse_day_file(path):
batch.append((code, str(date), o, h, l, c, v, amount))
total_stocks += 1
if len(batch) >= BATCH:
await flush()
if n % 500 == 0:
elapsed = time.time() - t0
print(f" 进度 {n}/{len(files)} 文件, 已入库 {total_stocks} 只股票, "
f"{rows_done + len(batch):,} 行, {elapsed:.0f}s")
await flush()
cnt = await conn.fetchval("SELECT count(*) FROM candles WHERE symbol <> 'DEMO'")
span = await conn.fetchrow(
"SELECT min(ts) AS lo, max(ts) AS hi FROM candles WHERE symbol <> 'DEMO'"
)
with_amt = await conn.fetchval(
"SELECT count(*) FROM candles WHERE symbol <> 'DEMO' AND amount IS NOT NULL"
)
print(f"\n完成: {total_stocks} 只股票, {cnt:,} 行日线, "
f"范围 {span['lo']:%Y-%m-%d} ~ {span['hi']:%Y-%m-%d}, "
f"含成交额 {with_amt:,} 行, "
f"跳过非股票文件 {skipped} 个, 耗时 {time.time() - t0:.0f}s")
finally:
await conn.close()
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="TDX 沪深京日线全量导入 candles")
ap.add_argument("root", help="hsjday 目录(其下 */lday/*.day")
ap.add_argument("symbols", nargs="*", help="可选的 6 位代码过滤")
ap.add_argument("--no-clear", action="store_true",
help="不清空任何行,纯 upsertamount 回补模式)")
a = ap.parse_args()
asyncio.run(main(Path(a.root), a.symbols or None, a.no_clear))

View File

@@ -0,0 +1,113 @@
"""交割单解析器离线自测:不碰数据库,直接调 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: 交割单解析器全部用例通过")

45
backend/uv.lock generated
View File

@@ -339,6 +339,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -698,6 +707,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "pandas"
version = "3.0.5"
@@ -878,6 +899,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -924,6 +954,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "redis"
version = "8.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
@@ -1075,9 +1114,12 @@ dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "numpy" },
{ name = "openpyxl" },
{ name = "pandas" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "redis" },
{ name = "sqlalchemy" },
{ name = "tushare" },
{ name = "uvicorn", extra = ["standard"] },
@@ -1091,9 +1133,12 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=2.2" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "redis", specifier = ">=8.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "tushare", specifier = ">=1.4" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },

693
backend_run.log Normal file
View File

@@ -0,0 +1,693 @@
INFO: Started server process [35612]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:61535 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:61572 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61574 - "GET /api/screener/preview/000001.SZ?limit=3&adjust=bfq HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:61576 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61578 - "GET /api/screener/preview/000001.SZ?limit=3 HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:61605 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61608 - "GET /api/screener/preview/000001.SZ?limit=3&adjust=bfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:62165 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62164 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1M&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62194 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62192 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1M&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62479 - "GET /api/screener/preview/000001.SZ?limit=3&adjust=bfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:62512 - "GET /api/screener/preview/000001.SZ?limit=2&adjust=bfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:63470 - "GET /api/screener/preview/000001.SZ?limit=2&adjust=qfq&timeframe=1M HTTP/1.1" 200 OK
INFO: 127.0.0.1:58419 - "GET /api/screener/preview/000001.SZ?limit=2800&adjust=bfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58464 - "GET /api/screener/preview/000001.SZ?limit=4&adjust=bfq&timeframe=1w HTTP/1.1" 200 OK
INFO: 127.0.0.1:58481 - "GET /api/screener/preview/000001.SZ?limit=2800&adjust=bfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58482 - "GET /api/screener/preview/000001.SZ?limit=4&adjust=bfq&timeframe=1w HTTP/1.1" 200 OK
INFO: 127.0.0.1:58776 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:58819 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:58820 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:58838 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:58855 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:58857 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:58867 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:58829 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58866 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58883 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:58881 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1M&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58985 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58988 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:58991 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59694 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59696 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59715 - "POST /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:59733 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59743 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59750 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59761 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59767 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59774 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59749 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59815 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59823 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59828 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59827 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59861 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59865 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59874 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59872 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59889 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59898 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59903 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59902 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59992 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:59990 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59998 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60003 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60018 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60455 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60453 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60458 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60476 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60474 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60478 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60498 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60497 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60501 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60513 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60512 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60515 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60556 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60554 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60559 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61756 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:61755 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61760 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63160 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63182 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63271 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:63290 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:63292 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:63330 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:63341 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:63349 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:63291 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63367 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:63377 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:63384 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:63348 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63380 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63403 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:63410 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:63413 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:63412 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:49400 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:49399 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:49461 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:49469 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:49479 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:49478 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51074 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:51097 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:51100 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:51099 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53759 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:53758 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53768 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53773 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53778 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2021-03-31 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53785 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2016-12-29 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53787 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2012-04-10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53793 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5%2C10%2C20%2C60&end=2008-10-10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53832 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53848 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C20%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53859 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53855 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:53852 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d&mas=60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53850 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5%2C60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53862 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53868 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53870 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53874 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53882 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53879 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53900 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53908 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53915 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53925 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53931 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53934 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53927 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53948 - "GET /api/screener/preview/000007.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53952 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53955 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53941 - "GET /api/screener/preview/000007.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54082 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:54085 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:54214 - "GET /api/screener/preview/000009.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54216 - "GET /api/screener/preview/000009.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54303 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:54301 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54936 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:54933 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1d&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54943 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1d&mas=5&end=2024-07-17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54950 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:54947 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54955 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59351 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:59369 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:59372 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:59371 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60036 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60035 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60042 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60069 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2000-05-15 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60229 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60228 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60245 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60392 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60388 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60395 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60399 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60407 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:60404 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5%2C10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60412 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60416 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5%2C10%2C20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60419 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:60423 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60537 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60536 - "GET /api/screener/preview/000008.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5%2C10%2C20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60544 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60691 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60702 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:60699 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60695 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60713 - "GET /api/screener/preview/000008.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-09-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60722 - "GET /api/screener/preview/000002.SZ?limit=500&adjust=qfq&timeframe=1w&mas=5%2C10%2C20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60730 - "GET /api/screener/preview/000002.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-12-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60763 - "GET /api/screener/preview/000002.SZ?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2000-11-13 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60775 - "DELETE /api/watchlist/000008.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60778 - "POST /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60783 - "DELETE /api/watchlist/000008.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60797 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:60898 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:60907 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:60916 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60897 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60914 - "POST /api/screener/run HTTP/1.1" 200 OK
INFO: 127.0.0.1:60972 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60971 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1w&mas=5%2C10%2C20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60988 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1w&mas=5%2C10%2C20&end=2016-12-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61042 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61032 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1w&mas=10%2C20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61034 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1w&mas=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61036 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1w HTTP/1.1" 200 OK
INFO: 127.0.0.1:61048 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1w&end=2016-12-26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61056 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:61059 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61063 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61075 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61078 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61082 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61106 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61165 - "GET /api/screener/preview/002668.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:61169 - "GET /api/screener/preview/002668.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62065 - "GET /api/screener/preview/002668.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62308 - "GET /api/screener/preview/002668.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 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 102, in require_user
auth_session = await get_auth_session(stock_session, db)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\app\auth.py", line 85, in get_auth_session
auth_session = (await db.execute(stmt)).scalar_one_or_none()
^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 448, in execute
result = await greenlet_spawn(
^^^^^^^^^^^^^^^^^^^^^
...<6 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
result = context.throw(*sys.exc_info())
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2373, in execute
return self._execute_internal(
~~~~~~~~~~~~~~~~~~~~~~^
statement,
^^^^^^^^^^
...<4 lines>...
_add_event=_add_event,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
conn = self._connection_for_bind(bind)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
return trans._connection_for_bind(engine, execution_options)
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 2, in _connection_for_bind
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
ret_value = fn(self, *arg, **kw)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
conn = bind.connect()
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
return self._connection_cls(self)
~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
self._dbapi_connection = engine.raw_connection()
~~~~~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
return self.pool.connect()
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
return _ConnectionFairy._checkout(self)
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 1272, in _checkout
fairy = _ConnectionRecord.checkout(pool)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 717, in checkout
with util.safe_reraise():
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
raise exc_value.with_traceback(exc_tb)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 715, in checkout
dbapi_connection = rec.get_connection()
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 866, in get_connection
self.__connect()
~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 900, in __connect
with util.safe_reraise():
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
raise exc_value.with_traceback(exc_tb)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 896, in __connect
self.dbapi_connection = connection = pool._invoke_creator(self)
~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\create.py", line 667, in connect
return dialect.connect(*cargs_tup, **cparams)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\default.py", line 630, in connect
return self.loaded_dbapi.connect(*cargs, **cparams) # type: ignore[no-any-return] # NOQA: E501
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\dialects\postgresql\asyncpg.py", line 955, in connect
await_only(creator_fn(*arg, **kw)),
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connection.py", line 2443, in connect
return await connect_utils._connect(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<22 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1249, in _connect
raise last_error or exceptions.TargetServerAttributeNotMatched(
...<2 lines>...
)
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1218, in _connect
conn = await _connect_addr(
^^^^^^^^^^^^^^^^^^^^
...<6 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1054, in _connect_addr
return await __connect_addr(params, True, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1099, in __connect_addr
tr, pr = await connector
^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 969, in _create_ssl_connection
tr, pr = await loop.create_connection(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
host, port)
^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1168, in create_connection
raise exceptions[0]
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1143, in create_connection
sock = await self._connect_sock(
^^^^^^^^^^^^^^^^^^^^^^^^^
exceptions, addrinfo, laddr_infos)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1042, in _connect_sock
await self.sock_connect(sock, address)
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\proactor_events.py", line 728, in sock_connect
return await self._proactor.connect(sock, address)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\windows_events.py", line 804, in _poll
value = callback(transferred, key, ov)
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\windows_events.py", line 600, in finish_connect
ov.getresult()
~~~~~~~~~~~~^^
ConnectionRefusedError: [WinError 1225] 远程计算机拒绝网络连接。
INFO: 127.0.0.1:62317 - "GET /api/screener/preview/002668.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 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 102, in require_user
auth_session = await get_auth_session(stock_session, db)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\app\auth.py", line 85, in get_auth_session
auth_session = (await db.execute(stmt)).scalar_one_or_none()
^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\ext\asyncio\session.py", line 448, in execute
result = await greenlet_spawn(
^^^^^^^^^^^^^^^^^^^^^
...<6 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 201, in greenlet_spawn
result = context.throw(*sys.exc_info())
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2373, in execute
return self._execute_internal(
~~~~~~~~~~~~~~~~~~~~~~^
statement,
^^^^^^^^^^
...<4 lines>...
_add_event=_add_event,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2261, in _execute_internal
conn = self._connection_for_bind(bind)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 2113, in _connection_for_bind
return trans._connection_for_bind(engine, execution_options)
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 2, in _connection_for_bind
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\state_changes.py", line 137, in _go
ret_value = fn(self, *arg, **kw)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\orm\session.py", line 1191, in _connection_for_bind
conn = bind.connect()
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 3295, in connect
return self._connection_cls(self)
~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 144, in __init__
self._dbapi_connection = engine.raw_connection()
~~~~~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\base.py", line 3319, in raw_connection
return self.pool.connect()
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 448, in connect
return _ConnectionFairy._checkout(self)
~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 1272, in _checkout
fairy = _ConnectionRecord.checkout(pool)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 717, in checkout
with util.safe_reraise():
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
raise exc_value.with_traceback(exc_tb)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 715, in checkout
dbapi_connection = rec.get_connection()
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 866, in get_connection
self.__connect()
~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 900, in __connect
with util.safe_reraise():
~~~~~~~~~~~~~~~~~^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\langhelpers.py", line 122, in __exit__
raise exc_value.with_traceback(exc_tb)
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\pool\base.py", line 896, in __connect
self.dbapi_connection = connection = pool._invoke_creator(self)
~~~~~~~~~~~~~~~~~~~~^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\create.py", line 667, in connect
return dialect.connect(*cargs_tup, **cparams)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\engine\default.py", line 630, in connect
return self.loaded_dbapi.connect(*cargs, **cparams) # type: ignore[no-any-return] # NOQA: E501
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\dialects\postgresql\asyncpg.py", line 955, in connect
await_only(creator_fn(*arg, **kw)),
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\sqlalchemy\util\_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connection.py", line 2443, in connect
return await connect_utils._connect(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<22 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1249, in _connect
raise last_error or exceptions.TargetServerAttributeNotMatched(
...<2 lines>...
)
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1218, in _connect
conn = await _connect_addr(
^^^^^^^^^^^^^^^^^^^^
...<6 lines>...
)
^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1054, in _connect_addr
return await __connect_addr(params, True, *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 1099, in __connect_addr
tr, pr = await connector
^^^^^^^^^^^^^^^
File "D:\Project\stock\backend\.venv\Lib\site-packages\asyncpg\connect_utils.py", line 969, in _create_ssl_connection
tr, pr = await loop.create_connection(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
host, port)
^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1168, in create_connection
raise exceptions[0]
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1143, in create_connection
sock = await self._connect_sock(
^^^^^^^^^^^^^^^^^^^^^^^^^
exceptions, addrinfo, laddr_infos)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\base_events.py", line 1042, in _connect_sock
await self.sock_connect(sock, address)
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\proactor_events.py", line 728, in sock_connect
return await self._proactor.connect(sock, address)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\windows_events.py", line 804, in _poll
value = callback(transferred, key, ov)
File "C:\Users\cirry\scoop\apps\python\current\Lib\asyncio\windows_events.py", line 600, in finish_connect
ov.getresult()
~~~~~~~~~~~~^^
ConnectionRefusedError: [WinError 1225] 远程计算机拒绝网络连接。
INFO: 127.0.0.1:62379 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62378 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:62397 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62416 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62425 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62423 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:62433 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:62415 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:62429 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:62437 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63003 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d&end=2018-10-29 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63007 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2016-10-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63659 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2016-10-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63684 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2016-10-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63711 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2016-10-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63732 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2016-10-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63759 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:63757 - "GET /api/screener/preview/600529.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:63765 - "GET /api/screener/preview/600529.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56553 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56569 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56572 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:56582 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56581 - "POST /api/screener/run HTTP/1.1" 200 OK
INFO: 127.0.0.1:56608 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56617 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56623 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:56628 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56627 - "POST /api/screener/run HTTP/1.1" 200 OK
INFO: 127.0.0.1:56694 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56691 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56732 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56744 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56747 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56746 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
==== restart 2026-08-15 23:03:42 ====
INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend']
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started reloader process [17652] using WatchFiles
INFO: Started server process [23704]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:61442 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61447 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:61456 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:61453 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61543 - "GET /api/stocks?limit=1 HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:61544 - "GET /api/stocks?limit=1 HTTP/1.1" 401 Unauthorized
WARNING: WatchFiles detected changes in 'app\api.py'. Reloading...
INFO: 127.0.0.1:65392 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:65410 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:65413 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:65412 - "GET /api/stocks?limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65424 - "GET /api/stocks?limit=3 HTTP/1.1" 200 OK
==== restart 2026-08-15 23:37:04 ====
INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend']
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started reloader process [48108] using WatchFiles
INFO: Started server process [47480]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:49363 - "GET /api/stocks?limit=3 HTTP/1.1" 200 OK
WARNING: WatchFiles detected changes in 'app\api.py'. Reloading...
==== restart 2026-08-15 23:46:54 ====
error: Failed to spawn: `uvicorn`
Caused by: program not found
INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend']
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started reloader process [42580] using WatchFiles
INFO: Started server process [46816]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:50670 - "GET /api/stocks?limit=1 HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:50683 - "GET /api/stocks?limit=3&sort=total_mv&order=desc HTTP/1.1" 200 OK
INFO: 127.0.0.1:50686 - "GET /api/stocks?limit=3&sort=turnover_rate&order=desc HTTP/1.1" 200 OK
INFO: 127.0.0.1:50687 - "GET /api/stocks?limit=3&sort=pe_ttm&order=asc HTTP/1.1" 200 OK
INFO: 127.0.0.1:50688 - "GET /api/stocks?limit=3& HTTP/1.1" 200 OK
INFO: 127.0.0.1:51208 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:51217 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:51224 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:51223 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51230 - "GET /api/stocks?sort=pe_ttm&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51231 - "GET /api/stocks?sort=pe_ttm&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51241 - "GET /api/stocks?sort=pe_ttm&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51243 - "GET /api/stocks?sort=pe_ttm&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51248 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51265 - "GET /api/stocks?sort=turnover_rate&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51273 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51315 - "GET /api/trades?ts_code=688825.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:51320 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:51319 - "GET /api/screener/preview/688825.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:51368 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:51375 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:51384 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51385 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:51845 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:51843 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:51958 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:51957 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:52092 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:52094 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:52455 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:52453 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:52454 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:52465 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK

View File

@@ -18,6 +18,9 @@ import type {
SyncRequest,
SyncResponse,
Timeframe,
TradesClearResponse,
TradesImportResponse,
UserTrade,
} from './types';
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
@@ -37,8 +40,11 @@ async function readError(res: Response, fallback: string): Promise<string> {
const body = await res.text();
if (!body) return fallback;
try {
const data = JSON.parse(body) as { detail?: string };
return data.detail || fallback;
const data = JSON.parse(body) as { detail?: unknown };
// FastAPI 校验类 422 的 detail 是对象数组,直接当字符串用会显示成 [object Object]
if (typeof data.detail === 'string') return data.detail;
if (data.detail != null) return JSON.stringify(data.detail);
return fallback;
} catch {
return body;
}
@@ -49,7 +55,9 @@ async function apiFetch(path: string, init: RequestInit = {}): Promise<Response>
...init,
credentials: 'include',
headers: {
...(init.body ? { 'Content-Type': 'application/json' } : {}),
// 仅 JSON字符串 body手工设 Content-TypeFormData 必须留给浏览器生成
// multipart 边界,手工设置会导致后端解析失败 422
...(typeof init.body === 'string' ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
@@ -158,6 +166,8 @@ export async function getStocks(params: {
industry?: string;
area?: string;
watched_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}): Promise<StockListResponse> {
@@ -167,6 +177,8 @@ export async function getStocks(params: {
if (params.industry) q.set('industry', params.industry);
if (params.area) q.set('area', params.area);
if (params.watched_only) q.set('watched_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0));
const res = await apiFetch(`/api/stocks?${q.toString()}`);
@@ -224,3 +236,27 @@ export async function deleteScreenerQuery(id: number): Promise<void> {
const res = await apiFetch(`/api/screener/queries/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 401) throw new ApiError(`删除失败 (HTTP ${res.status})`, res.status);
}
// ---------- 交割单(个人实盘买卖点) ----------
export async function getTrades(tsCode?: string): Promise<UserTrade[]> {
const q = tsCode ? `?ts_code=${encodeURIComponent(tsCode)}` : '';
const res = await apiFetch(`/api/trades${q}`);
if (!res.ok) throw new ApiError(await readError(res, `获取成交记录失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as UserTrade[];
}
/** 上传交割单文件CSV/Excel/HTML 均可,后端自动识别列名与编码)。 */
export async function importTrades(file: File): Promise<TradesImportResponse> {
const form = new FormData();
form.append('file', file);
// 注意:不能手工设 Content-TypeFormData 需自带 multipart 边界
const res = await apiFetch('/api/trades/import', { method: 'POST', body: form });
if (!res.ok) throw new ApiError(await readError(res, `导入失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as TradesImportResponse;
}
export async function clearTrades(): Promise<TradesClearResponse> {
const res = await apiFetch('/api/trades', { method: 'DELETE' });
if (!res.ok) throw new ApiError(await readError(res, `清空成交失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as TradesClearResponse;
}

View File

@@ -211,7 +211,11 @@ export interface StockListItem {
prev_close?: number | null;
pct_chg?: number | null;
last_ts?: string | null;
bar_count?: number | null;
turnover_rate?: number | null; // 换手率 %daily_snapshot
pe_ttm?: number | null;
pb?: number | null;
total_mv?: number | null; // 总市值(亿元)
circ_mv?: number | null; // 流通市值(亿元)
watched: boolean;
}
@@ -257,6 +261,32 @@ export interface ScreenerQueryItem {
created_at: string;
}
// ---------- 交割单(个人实盘买卖点,镜像 app/schemas.py ----------
export interface UserTrade {
id: number;
ts_code: string;
name?: string | null;
trade_date: string; // ISO YYYY-MM-DD
direction: 'buy' | 'sell';
price?: number | null; // 券商原始成交价(不复权)
qty: number;
amount?: number | null;
fee?: number | null;
}
export interface TradesImportResponse {
inserted: number;
skipped_dup: number;
skipped_other: number;
stocks: number;
bad: string[];
sample: UserTrade[];
}
export interface TradesClearResponse {
deleted: number;
}
// ---------- 事件回测(自然语言) ----------
export interface EventBacktestSpec {
entry: ScreenConditions;

View File

@@ -2,7 +2,7 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
dispose, init, registerIndicator, registerOverlay,
type Chart, type KLineData, type Point,
type Chart, type KLineData, type OverlayCreate, type OverlayTemplate, type Point,
} from 'klinecharts';
// 官方画线扩展preview.klinecharts.com 同款工具集rect/circle 沿用 v10 内置版,不注册扩展的重名模板
import {
@@ -22,6 +22,50 @@ for (const t of [
registerOverlay(t);
}
// ---------- 实盘买卖点标记(交割单导入) ----------
// v10 无 v9 的 simpleMarker须注册自定义模板字母种类/明细经 extendData 传入。
// A股惯例通达信/同花顺同款B 买贴 low 下方、S 卖贴 high 上方、T 当日买+卖做T贴 high 上方;
// 图上只显示单个字母徽章(色底白字,用户指定固定配色,不随涨跌设置),成交明细(数量/均价/费用)
// 悬停字母时由组件浮层展示——onMouseEnter/onMouseLeave 是创建项级回调OverlayCreate 未 Omit
// 事件键),闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
interface TradeRow { label: string; text: string; tone: 'buy' | 'sell' | '' }
interface TradeMarkExt { kind: 'B' | 'S' | 'T'; rows: TradeRow[] }
const TRADE_COLORS: Record<'B' | 'S' | 'T', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504' };
const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
name: 'tradeMarker',
totalStep: 2,
needDefaultPointFigure: false,
needDefaultXAxisFigure: false,
needDefaultYAxisFigure: false,
createPointFigures: ({ overlay, coordinates }) => {
const c = coordinates[0];
const ext = overlay.extendData;
if (!c || !ext) return [];
const ly = ext.kind === 'B' ? c.y + 22 : c.y - 22; // 字母中心与 bar 高低点的像素间距离K线远一点更清爽
return [
{
type: 'text',
attrs: { x: c.x, y: ly, text: ext.kind, align: 'center', baseline: 'middle' },
styles: {
color: '#FFFFFF', backgroundColor: TRADE_COLORS[ext.kind],
size: 12, weight: 'bold', borderRadius: 3,
paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1,
},
ignoreEvent: true,
},
{ // 透明命中区:把字母徽章的悬停判定兜成 r=9 的圆,指上去更容易。
// 必须排在 text 之后:库按数组顺序挂 children、倒序分发 mousemove
// circle 放最后才能最先接管事件——否则首个落点在徽章上时 enter 会被
// text 的 ignoreEvent 拦住、tooltip 出不来circle 全透明,压顶层无视觉影响)
type: 'circle',
attrs: { x: c.x, y: ly, r: 9 },
styles: { style: 'fill', color: 'rgba(0,0,0,0)', borderColor: 'rgba(0,0,0,0)' },
},
];
},
};
registerOverlay(tradeMarkerTemplate);
const props = defineProps<{
ticker: string;
candles: Candle[];
@@ -45,6 +89,17 @@ const props = defineProps<{
timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
tooltipFields?: TooltipField[];
/** 日期跳转锚点本地零点时间戳build 完成后把该日 K 线滚动到可视区中央null=停在最新 */
centerTs?: number | null;
/** 实盘买卖点交割单导入按日聚合成标记B=当日只买 贴 low 下方、S=当日只卖 贴 high 上方、
* T=当日买+卖做T贴 high 上方rows 为悬停明细(数量/均价/费用)。
* 只画落在已渲染窗口内的(更早的等左滑翻页后自动补画) */
tradeMarkers?: { key: string; ts: number; kind: 'B' | 'S' | 'T'; rows: TradeRow[] }[];
}>();
const emit = defineEmits<{
/** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */
(e: 'centerMiss', ts: number): void;
}>();
// A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色
@@ -246,7 +301,11 @@ function darkStyles() {
horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#333A45' } },
},
separator: { color: '#23252B' },
separator: {
color: '#23252B',
// 悬停/拖拽分隔条时的底色(库默认 8% 蓝在纯黑底上不可见,加重为可感知的拖拽提示)
activeBackgroundColor: 'rgba(37, 99, 235, 0.30)',
},
};
}
@@ -254,6 +313,23 @@ function darkStyles() {
const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90);
// ---------- 分隔条拖拽调高(库原生 SeparatorWidget→ 持久化 ----------
// build 时记录 key→paneId拖动中库会高频触发 onPaneDrag防抖后读回各副图实际高度写入偏好
let paneIdByKey: Record<string, string> = {};
let subHTimer: ReturnType<typeof setTimeout> | null = null;
function persistSubHeights() {
if (!chart) return;
const next: Record<string, number> = {};
for (const key of props.subPanes) {
const pid = paneIdByKey[key];
const h = pid ? (chart.getPaneOptions(pid) as { height?: number } | null)?.height : undefined;
if (typeof h === 'number') next[key] = Math.max(40, Math.round(h));
}
if (Object.keys(next).length === 0) return;
settings.setChartLayout({ subHeights: { ...props.subHeights, ...next } });
}
// ---------- 鼠标跟随信息框(通达信式,浮层贴鼠标,每行一个指标) ----------
interface TipRow { key: string; label: string; text: string; tone: '' | 'up' | 'down' }
interface HoverInfo {
@@ -426,6 +502,113 @@ function pickTool(key: string) {
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
// removeOverlay() 无参清的是全部 overlay含交易点——交易点不是用户画线重画回来
renderTradeMarkers();
}
// ---------- 日期跳转居中 ----------
/** ts本地零点落在哪根K上取该时刻之前含同日最近一根的下标无则 -1停牌/非交易日自然落到前一根 */
function idxAtOrBefore(list: KLineData[], ts: number): number {
let lo = 0, hi = list.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (list[mid].timestamp <= ts) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/** 把已渲染的第 i 根K线滚动到可视区中央scrollToDataIndex 定位到右缘,补半个可视窗口即居中) */
function centerDataIndex(i: number) {
if (!chart) return;
const v = chart.getVisibleRange();
const vis = Math.max(2, Math.round(v.to - v.from) - 1); // from/to 含半个bar余量
chart.scrollToDataIndex(i + Math.floor(vis / 2) - 1, 350);
}
/** 对外:把某天滚动到可视区中央;目标不在当前已渲染窗口内时返回 false调用方走重拉窗口
* ts 晚于最后一根 10 天以上(未来日期/超出现有数据)同样算失败,避免 floor 搜索落到
* 最后一根、锚点却指向一个不存在交易的日期10 天容忍周末与春节黄金周这类停牌间隙。 */
const FUTURE_TOL_MS = 10 * 86400000;
function centerOn(ts: number): boolean {
if (!chart) return false;
const list = chart.getDataList();
if (list.length === 0) return false;
const i = idxAtOrBefore(list, ts);
if (i < 0) return false;
if (ts > list[list.length - 1].timestamp + FUTURE_TOL_MS) return false;
centerDataIndex(i);
return true;
}
defineExpose({ centerOn });
// ---------- 实盘买卖点标记渲染 ----------
const TRADE_GROUP = 'trades';
/** 交易点允许吸附到「晚于最后一根K时间戳」的窗口按周期放大周/月/年K的 bar 时间戳
* 是周期首日(周一/1日/1月1日当前周期内的成交如月中仍应贴到最后一根上。
* 日K严格为 0行情未同步到成交日时宁可先不画数据同步后重建图表自动补上
* 也不能把周一的成交错标到周五的K线上。 */
const TRADE_AHEAD_MS: Record<string, number> = {
'1d': 0,
'1w': 6 * 86400000,
'1M': 31 * 86400000,
'1y': 366 * 86400000,
};
/** 按 groupId 整组重建买卖点标记(先删后建,幂等)。交易日期按时间戳吸附到所在 bar
* B 贴 bar.low 下方、S/T 贴 bar.high 上方坐标随复权切换自动重算value 取自当前数据)。
* 早于已渲染窗口的交易先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
* 列表为空(关闭显示/清空成交/切到无成交股票)也必须清组,否则旧标记残留。 */
function renderTradeMarkers() {
if (!chart) return;
tradeTip.value = null; // 组重建期间字母已换位,旧明细浮层不能留在原地
chart.removeOverlay({ groupId: TRADE_GROUP });
if (!props.tradeMarkers?.length) return;
const list = chart.getDataList();
if (list.length === 0) return;
const lastTs = list[list.length - 1].timestamp;
const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0;
const creates: OverlayCreate<unknown>[] = [];
for (const m of props.tradeMarkers) {
const i = idxAtOrBefore(list, m.ts);
if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期
const bar = list[i];
creates.push({
id: `trade-${m.key}`,
groupId: TRADE_GROUP,
name: 'tradeMarker',
points: [{ timestamp: bar.timestamp, value: m.kind === 'B' ? bar.low : bar.high }],
extendData: { kind: m.kind, rows: m.rows },
onMouseEnter: (ev) => {
// pageX/pageY 是文档绝对坐标x/y 是相对各 pane 画布的,副图 pane 会带偏移),而
// getBoundingClientRect 是视口坐标——须再减 window.scrollX/Y 对齐基准:浮层是从滚过的
// 列表页打开的body 锁滚仍保留偏移),漏减会把 tip 整体顶出可视区、悬停像失灵
const rect = container.value?.getBoundingClientRect();
const px = (ev.pageX ?? 0) - (rect?.left ?? 0) - window.scrollX;
const py = (ev.pageY ?? 0) - (rect?.top ?? 0) - window.scrollY;
tradeTip.value = { ...placeTradeTip(px, py, m.rows.length), kind: m.kind, date: m.key, rows: m.rows };
},
onMouseLeave: () => { tradeTip.value = null; },
// v10 右键命中 figure 会默认 removeOverlaylock 只拦左键按下),标记被悄悄删掉——显式吞掉
onRightClick: (ev) => { ev.preventDefault?.(); },
lock: true,
});
}
if (creates.length) chart.createOverlay(creates);
}
// ---------- 交易点悬停明细(悬停 B/S/T 字母才显示,离开/滚动即隐) ----------
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T'; date: string; rows: TradeRow[] }
const tradeTip = ref<TradeTip | null>(null);
/** 贴鼠标定位并在右缘/下缘自动翻转(与十字线浮层 placeHover 同款策略,宽度略大) */
function placeTradeTip(px: number, py: number, rowCount: number): { x: number; y: number } {
const w = container.value?.clientWidth ?? 800;
const h = container.value?.clientHeight ?? 500;
const bw = 168, bh = 36 + rowCount * 17, gap = 12;
const x = px + gap + bw > w - 4 ? Math.max(4, px - gap - bw) : px + gap;
const y = py + gap + bh > h - 4 ? Math.max(4, py - gap - bh) : py + gap;
return { x, y };
}
function build() {
@@ -464,12 +647,20 @@ function build() {
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
};
const answerEmpty = () => callback([], { forward: false, backward: false });
if (type === 'init') {
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
served = Math.min(INIT_BARS, allData.length);
callback(allData.slice(allData.length - served), { forward: canBack(), backward: false });
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
// 有跳转锚点时把 serve 左扩到包含锚点(锚点落在窗口前 1/2 处),仍保持
// [n-served, n) 尾连续不变式——这样 serveOlder 的翻页切片不用变;
// BOLL/副图等同数据重建时锚点就不会掉出首屏窗口。
const n = allData.length;
const anchorIdx = props.centerTs != null ? idxAtOrBefore(allData, props.centerTs) : -1;
served = anchorIdx >= 0
? Math.min(n, Math.max(INIT_BARS, n - anchorIdx + (INIT_BARS >> 1)))
: Math.min(INIT_BARS, n);
callback(allData.slice(n - served), { forward: canBack(), backward: false });
maybePrefetch(myEpoch);
} else if (type === 'forward') {
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
@@ -505,38 +696,62 @@ function build() {
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
// minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致)
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
paneIdByKey = {};
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
ch.createIndicator(name);
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
if (paneId) {
paneIdByKey[key] = paneId;
ch.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}
bindCrosshair(ch);
// 分隔条拖拽调高拖动结束250ms 无新事件)后把各副图实际高度持久化;
// 期间图表已重建epoch 变化)则丢弃,新图表按存档布局
ch.subscribeAction('onPaneDrag', () => {
if (myEpoch !== epoch) return;
if (subHTimer) clearTimeout(subHTimer);
subHTimer = setTimeout(() => { subHTimer = null; persistSubHeights(); }, 250);
});
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
ch.subscribeAction('onVisibleRangeChange', (payload) => {
if (myEpoch !== epoch) return;
tradeTip.value = null; // 滚动后字母随 bar 移位,悬停明细立即失效
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
renderTradeMarkers();
}
function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (container.value) dispose(container.value);
chart = null;
hover.value = null;
tradeTip.value = null;
activeTool.value = '';
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 买卖点数据变化(导入/清空/开关显示):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
@@ -544,11 +759,11 @@ watch(() => props.subHeights, () => {
if (!chart) return;
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value?.clientHeight || 560;
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key) });
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}, { deep: true });
</script>
@@ -556,12 +771,13 @@ watch(() => props.subHeights, () => {
<template>
<!-- mousemove captureklinecharts 在内部容器上以冒泡阶段监听并同步触发
onCrosshairChangeplaceHovercapture 先于它更新 mx/my避免用到上一次的坐标 -->
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null">
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null; tradeTip = null">
<div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定 -->
<!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定
悬停交易字母时让位给明细浮层两框几乎同点位叠加会呈现双层边框的重影 -->
<div
v-if="hover"
v-if="hover && !tradeTip"
class="pointer-events-none absolute z-10 w-40 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="hoverStyle"
>
@@ -578,8 +794,33 @@ watch(() => props.subHeights, () => {
</div>
</div>
<!-- 画图画线工具栏常用一行 + 更多分组面板 -->
<div class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm">
<!-- 交易点悬停明细贴鼠标/下缘自动翻转日期 + 字母 + 当日买卖数量/均价/费用 -->
<div
v-if="tradeTip"
class="pointer-events-none absolute z-20 w-44 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="{ left: `${tradeTip.x}px`, top: `${tradeTip.y}px` }"
>
<div class="flex items-baseline justify-between">
<span class="text-[#9BA3AE]">{{ tradeTip.date }}</span>
<span class="font-bold" :style="{ color: TRADE_COLORS[tradeTip.kind] }">{{ tradeTip.kind }}</span>
</div>
<div class="mt-1 border-t border-[#33353D]/60 pt-1">
<div v-for="(r, i) in tradeTip.rows" :key="i" class="flex items-baseline justify-between">
<span class="text-[#A8AFB8]">{{ r.label }}</span>
<span
:style="r.tone ? { color: r.tone === 'buy' ? TRADE_COLORS.B : TRADE_COLORS.S } : undefined"
:class="r.tone ? '' : 'text-[#E8EAED]'"
>{{ r.text }}</span>
</div>
</div>
</div>
<!-- 画图画线工具栏常用一行 + 更多分组面板
移入工具栏时 canvas 收不到后续 mousemoveonMouseLeave 不会触发须在此清掉交易明细浮层 -->
<div
class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm"
@mouseenter="tradeTip = null"
>
<div class="flex items-center gap-0.5 px-1 py-0.5">
<button
v-for="t in COMMON_TOOLS"

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue';
import { useSettingsStore, type PriceAdjust, type PriceTone } from '@/stores/settings';
const emit = defineEmits<{ (e: 'close'): void }>();
const settings = useSettingsStore();
// ---------- 分类:行情配色 ----------
const TONES: { key: PriceTone; label: string; desc: string; up: string; down: string }[] = [
{ key: 'red-up', label: '红涨绿跌', desc: 'A 股风格', up: '#FE354B', down: '#1EBE72' },
{ key: 'green-up', label: '绿涨红跌', desc: '美股风格', up: '#1EBE72', down: '#FE354B' },
];
// ---------- 分类K线复权 ----------
const ADJUSTS: { key: PriceAdjust; label: string; desc: string }[] = [
{ key: 'bfq', label: '不复权', desc: '原始价格,含除权跳空' },
{ key: 'qfq', label: '前复权', desc: '以最新价为基准,看趋势最常用' },
{ key: 'hfq', label: '后复权', desc: '以上市价为基准,看累计涨幅' },
];
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
</script>
<template>
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 backdrop-blur-sm" @click.self="emit('close')">
<div class="w-full max-w-md rounded-lg border border-[#26272E] bg-[#101014] shadow-xl shadow-black/60" role="dialog" aria-label="设置">
<!-- 头部 -->
<div class="flex items-center justify-between border-b border-[#1E2026] px-5 py-3.5">
<h2 class="text-sm font-semibold text-[#E8EAED]">设置</h2>
<button
type="button"
class="rounded p-1 text-[#9BA3AE] transition-colors hover:bg-[#1E2026] hover:text-[#E8EAED]"
title="关闭 (Esc)"
@click="emit('close')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<!-- 分类一行情配色 -->
<div class="px-5 py-4">
<div class="text-[13px] font-medium tracking-wide text-[#A8AFB8]">行情配色</div>
<p class="mt-1 text-[13px] text-[#9BA3AE]">设置全站涨跌颜色立即生效并自动记住</p>
<div class="mt-3 grid grid-cols-2 gap-3">
<button
v-for="t in TONES"
:key="t.key"
type="button"
class="rounded-lg border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="settings.priceTone === t.key
? 'border-blue-500 bg-blue-500/15 ring-1 ring-blue-500'
: 'border-[#26272E] hover:border-[#3A3D46]'"
@click="settings.setPriceTone(t.key)"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-[#E8EAED]">{{ t.label }}</span>
<span
v-if="settings.priceTone === t.key"
class="grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white"
>
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
</span>
</div>
<div class="mt-1 text-[13px] text-[#9BA3AE]">{{ t.desc }}</div>
<!-- 效果预览 -->
<div class="mt-2.5 flex items-baseline gap-3 font-mono text-sm">
<span :style="{ color: t.up }">+2.50%</span>
<span :style="{ color: t.down }">-1.30%</span>
</div>
</button>
</div>
</div>
<!-- 分类二K线复权 -->
<div class="border-t border-[#1E2026] px-5 py-4">
<div class="text-[13px] font-medium tracking-wide text-[#A8AFB8]">K线复权</div>
<p class="mt-1 text-[13px] text-[#9BA3AE]">个股详情 K 线的默认口径浮层内也可随时切换</p>
<div class="mt-3 grid grid-cols-3 gap-3">
<button
v-for="a in ADJUSTS"
:key="a.key"
type="button"
class="rounded-lg border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="settings.priceAdjust === a.key
? 'border-blue-500 bg-blue-500/15 ring-1 ring-blue-500'
: 'border-[#26272E] hover:border-[#3A3D46]'"
@click="settings.setPriceAdjust(a.key)"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-[#E8EAED]">{{ a.label }}</span>
<span
v-if="settings.priceAdjust === a.key"
class="grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white"
>
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
</span>
</div>
<div class="mt-1 text-[13px] text-[#9BA3AE]">{{ a.desc }}</div>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField } from '@/api/types';
import {
addWatchlist, clearTrades, getStockPreview, getTrades, getWatchlist as getWatchlistApi,
importTrades, removeWatchlist,
} from '@/api/client';
import type {
ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField,
TradesImportResponse, UserTrade,
} from '@/api/types';
import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
import DetailKLine from './DetailKLine.vue';
@@ -9,7 +15,12 @@ const props = defineProps<{
items: ScreenerItemOut[];
initial: string; // ts_code
}>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'watched-change'): void }>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'watched-change'): void;
/** 浮层内切股(键盘 ↑/↓、侧栏点击)时上报当前 ts_code父组件据此同步路由 */
(e: 'change', code: string): void;
}>();
const settings = useSettingsStore();
// ---------- 状态 ----------
@@ -43,11 +54,6 @@ function setTimeframe(tf: Timeframe) {
settings.setChartLayout({ timeframe: tf });
}
// 数据口径徽标market=近段未复权兜底;其余为实际复权口径(可能因因子缺失与所选不同)
const ADJUST_LABELS: Record<string, string> = { bfq: '不复权', qfq: '前复权', hfq: '后复权' };
const sourceLabel = computed(() =>
data.value ? (ADJUST_LABELS[data.value.source] ?? data.value.source) : '');
// ---------- 副图 / MA / 高度(全部随用户偏好持久化) ----------
const SUBS = [
{ key: 'vol', label: 'VOL' },
@@ -68,12 +74,7 @@ function toggleSub(key: string) {
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
function adjustHeight(key: string, delta: number) {
const DEFAULTS: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const base = subHeights.value;
const next = Math.max(40, (base[key] ?? DEFAULTS[key] ?? 90) + delta);
settings.setChartLayout({ subHeights: { ...base, [key]: next } });
}
// 副图高度改为图内分隔条直接拖拽DetailKLine 订阅 onPaneDrag 持久化),此处不再提供按钮
// 副图拖拽排序
let dragKey: string | null = null;
@@ -120,8 +121,57 @@ function toggleTipField(key: TooltipField) {
tooltipFields: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
function resetTipFields() {
settings.setChartLayout({ tooltipFields: [...DEFAULT_TOOLTIP_FIELDS] });
// ---------- 日期跳转(输入 YYYYMMDD把该日K线定位到可视区中央 ----------
const JUMP_END_DAYS = 170; // 锚点 + 170 自然日 ≈ 120 个交易日:锚点恰落在 240 根首吐窗口正中
const jumpInput = ref('');
const jumpErr = ref('');
const jumpTs = ref<number | null>(null); // 当前锚点本地零点时间戳null=最新行情模式
const klineRef = ref<InstanceType<typeof DetailKLine> | null>(null);
/** '20200218' → 本地零点时间戳(与 Candle.ts 的反序列化口径一致精确命中当日K线非法返回 null */
function parseJumpDate(raw: string): number | null {
if (!/^\d{8}$/.test(raw)) return null;
const y = +raw.slice(0, 4), m = +raw.slice(4, 6), d = +raw.slice(6, 8);
const dt = new Date(y, m - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
if (y < 1990 || y > 2099) return null;
return dt.getTime();
}
/** 时间戳 → 'YYYY-MM-DD'(后端 end 参数格式) */
function fmtDashDate(ms: number): string {
const dt = new Date(ms);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
function onJumpInput() {
jumpInput.value = jumpInput.value.replace(/\D/g, '').slice(0, 8);
jumpErr.value = '';
}
function jumpToDate() {
const ts = parseJumpDate(jumpInput.value.trim());
if (ts == null) { jumpErr.value = '日期格式20200218'; return; }
jumpErr.value = '';
// 非日K周期先切回日K再跳watcher 重拉时 load 会带上刚设好的锚点)
if (timeframe.value !== '1d') { jumpTs.value = ts; setTimeframe('1d'); return; }
// 快路径:目标日已在当前渲染窗口内 → 纯滚动居中,不打网络
if (klineRef.value?.centerOn(ts)) { jumpTs.value = ts; return; }
// 慢路径:目标日不在窗口内 → 以锚点为中点重拉一窗end 不含当日,故右缘=锚点+170 自然日)
jumpTs.value = ts;
void load(active.value);
}
/** 清除锚点回到最新行情(锚定窗口的右缘停在锚点日之后,需要这个出口) */
function clearJump() {
jumpTs.value = null;
jumpErr.value = '';
void load(active.value);
}
/** DetailKLine 上报:锚点在拉回的数据里也找不到(早于上市首日,或晚于最后一根的未来日期)。
* 统一走与「空数据」相同的回退:清锚点、提示、回最新行情,避免 chip/统计停留在假锚定状态。 */
function onCenterMiss() {
if (jumpTs.value == null) return;
jumpTs.value = null;
jumpErr.value = '该日期无K线早于上市或晚于最新数据已回到最新行情';
void load(active.value);
}
// ---------- 自选股(星标) ----------
@@ -149,6 +199,110 @@ async function toggleWatch() {
}
}
// ---------- 实盘交易点(交割单导入) ----------
const trades = ref<UserTrade[]>([]);
const showTrades = ref(true);
const showTradeImport = ref(false);
const importing = ref(false);
const importResult = ref<TradesImportResponse | null>(null);
const importErr = ref<string | null>(null);
/** 拉当前股的实盘成交(失败静默:未登录/网络异常都不影响看图)。
* 与 load() 同款的请求序号:切股瞬间并发拉 K 线与成交,乱序返回的旧股
* 成交绝不能回填——否则旧股买卖点会画到新股K线上。 */
let tradesToken = 0;
async function loadTrades(code: string) {
const token = ++tradesToken;
try {
const list = await getTrades(code);
if (token === tradesToken) trades.value = list;
} catch {
if (token === tradesToken) trades.value = [];
}
}
watch(active, (code) => {
trades.value = []; // 同步先清:新图挂载时(成交未返回)不能带着旧股标记
loadTrades(code);
}, { immediate: true });
const fmtQty = (q: number) =>
q >= 10000 ? `${(q / 10000).toFixed(1).replace(/\.0$/, '')}` : String(Math.round(q));
const fmtPrice = (p: number) => p.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
/** 按日聚合成标记:图上只显示 B/S/T 单个字母B=当日只买 贴 low 下方、S=当日只卖 贴 high 上方、
* T=当日买+卖「做T」贴 high 上方);数量/均价/费用收进 rows悬停字母时才显示。
* 均价是券商原始成交价的股数加权均值(不复权口径,仅作参考——标记位置贴 bar 高低点,
* 已随复权切换自动对齐)。 */
const tradeMarkers = computed(() => {
if (!showTrades.value) return [];
const byDay = new Map<string, { ts: number; list: UserTrade[] }>();
for (const t of trades.value) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t.trade_date);
if (!m) continue;
let d = byDay.get(m[0]);
if (!d) byDay.set(m[0], d = { ts: new Date(+m[1], +m[2] - 1, +m[3]).getTime(), list: [] });
d.list.push(t);
}
const sumBy = (list: UserTrade[], f: (t: UserTrade) => number) => list.reduce((s, t) => s + f(t), 0);
const dirRow = (label: string, list: UserTrade[], tone: 'buy' | 'sell') => {
const qty = sumBy(list, (t) => t.qty);
const wsum = sumBy(list, (t) => (t.price ?? 0) * t.qty);
const wqty = sumBy(list, (t) => (t.price != null ? t.qty : 0));
const avg = wqty > 0 ? wsum / wqty : null;
return { label, text: `${fmtQty(qty)}${avg != null ? ` @ ${fmtPrice(avg)}` : ''}`, tone };
};
return [...byDay.entries()].map(([date, d]) => {
const buys = d.list.filter((t) => t.direction === 'buy');
const sells = d.list.filter((t) => t.direction === 'sell');
const kind: 'B' | 'S' | 'T' = buys.length && sells.length ? 'T' : buys.length ? 'B' : 'S';
const rows: { label: string; text: string; tone: 'buy' | 'sell' | '' }[] = [
...(buys.length ? [dirRow('买入', buys, 'buy')] : []),
...(sells.length ? [dirRow('卖出', sells, 'sell')] : []),
];
const fee = sumBy(d.list, (t) => t.fee ?? 0);
if (fee > 0) rows.push({ label: '费用', text: fmtPrice(fee), tone: '' });
return { key: date, ts: d.ts, kind, rows };
}).sort((a, b) => a.ts - b.ts);
});
async function onTradeFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
importing.value = true;
importResult.value = null;
importErr.value = null;
try {
importResult.value = await importTrades(file);
await loadTrades(active.value);
} catch (err) {
importErr.value = err instanceof Error ? err.message : '导入失败';
} finally {
importing.value = false;
(e.target as HTMLInputElement).value = ''; // 允许重选同一文件
}
}
async function onClearTrades() {
if (!window.confirm('确定清空全部股票的成交记录?此操作不可恢复(需重新导入交割单)。')) return;
try {
await clearTrades();
trades.value = [];
importResult.value = null;
importErr.value = null;
} catch (err) {
importErr.value = err instanceof Error ? err.message : '清空失败';
}
}
/** 打开导入弹窗:清掉上一次的结果/错误,避免误读为本次操作的结果 */
function openTradeImport() {
importResult.value = null;
importErr.value = null;
showMaConfig.value = false;
showTipConfig.value = false;
showTradeImport.value = true;
}
const filteredItems = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.items;
@@ -176,17 +330,28 @@ const header = computed(() => {
let fetchToken = 0;
async function load(code: string) {
const token = ++fetchToken;
const anchor = jumpTs.value; // 跳转锚点:之后的所有重拉(复权/周期/MA都围绕它取窗视口位置不漂
loading.value = true;
error.value = null;
data.value = null;
try {
const res = await getStockPreview(code, {
limit: 500,
// 锚定取数:窗口右缘=锚点+170 自然日(后端 end 不含当日),锚点恰好落在 240 根首吐窗口正中
end: anchor != null ? fmtDashDate(anchor + JUMP_END_DAYS * 86400000) : undefined,
adjust: adjust.value,
timeframe: timeframe.value,
mas: maPeriods.value,
});
if (token === fetchToken) data.value = res;
if (token !== fetchToken) return;
if (anchor != null && res.candles.length === 0) {
// 跳转日期早于上市等锚定窗口取不到任何K线 → 退回最新行情
jumpTs.value = null;
jumpErr.value = '该日期无数据(可能早于上市),已回到最新行情';
void load(code);
return;
}
data.value = res;
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
@@ -211,7 +376,13 @@ async function loadOlder(end: string, count: number) {
return null; // 网络失败:图表停止向前翻页(不中断已渲染内容)
}
}
watch(active, (code) => load(code), { immediate: true });
watch(active, (code) => {
// 切股清空日期锚点:新股票以最新行情打开
jumpTs.value = null;
jumpErr.value = '';
load(code);
emit('change', code); // 父组件把当前股写进路由,刷新后可还原
}, { immediate: true });
watch(adjust, () => load(active.value));
watch(timeframe, () => load(active.value));
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
@@ -236,10 +407,14 @@ function onKeydown(e: KeyboardEvent) {
if (e.isComposing) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// 弹层打开时接管按键Esc 关弹层,其余(含 ↑/↓)不再切换底层个股
const modalOpen = showTradeImport.value || showMaConfig.value || showTipConfig.value;
if (e.key === 'Escape') {
if (showMaConfig.value || showTipConfig.value) { showMaConfig.value = false; showTipConfig.value = false; }
if (showTradeImport.value) showTradeImport.value = false;
else if (showMaConfig.value || showTipConfig.value) { showMaConfig.value = false; showTipConfig.value = false; }
else emit('close');
}
else if (modalOpen) return;
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
}
@@ -254,7 +429,8 @@ onBeforeUnmount(() => {
// ---------- 右侧信息栏增强52周高低 / 年初至今(从日线序列算,无数据留空) ----------
const stats = computed(() => {
const bars = timeframe.value === '1d' ? data.value?.candles : null;
// 日期跳转后窗口是历史段52周/年初至今口径失真,直接不显示
const bars = timeframe.value === '1d' && jumpTs.value == null ? data.value?.candles : null;
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
const last = bars[bars.length - 1];
const lastTs = new Date(last.ts);
@@ -333,18 +509,8 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
@click="setAdjust(a.key)"
>{{ a.label }}</button>
</div>
<span v-if="data?.source === 'market'" class="rounded bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
近段未复权数据
</span>
<span
v-else-if="data"
class="rounded px-2 py-0.5 text-xs"
:class="data.source === adjust ? 'bg-blue-500/15 text-blue-300' : 'bg-amber-500/15 text-amber-300'"
:title="data.source === adjust ? '' : '该股复权因子缺失,暂按此口径显示(可先同步市场数据)'"
>{{ sourceLabel }}</span>
<span class="ml-auto text-[13px] text-[#9BA3AE]"> 切换 · Esc 关闭 · 滚轮缩放 · 左滑加载历史</span>
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
<button type="button" class="btn-ghost !px-2.5 !py-1 ml-auto cursor-pointer" title="关闭 (Esc)" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</header>
@@ -399,7 +565,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:class="subPanes.includes(s.key)
? 'bg-blue-600 text-white'
: 'bg-[#101014] text-[#9BA3AE] line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 图内分隔线拖拽调高度' : '点击显示'"
@click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)"
@dragover.prevent
@@ -407,10 +573,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
>
{{ s.label }}
</button>
<template v-if="subPanes.includes(s.key)">
<button type="button" class="border-l px-1 py-1 text-xs text-[#9BA3AE] hover:bg-[#1E2026] hover:text-[#E8EAED]" title="调高" @click="adjustHeight(s.key, 20)"></button>
<button type="button" class="border-l px-1 py-1 text-xs text-[#9BA3AE] hover:bg-[#1E2026] hover:text-[#E8EAED]" title="调矮" @click="adjustHeight(s.key, -20)"></button>
</template>
</div>
<button
type="button"
@@ -419,6 +581,20 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
title="主图叠加布林带"
@click="showBoll = !showBoll"
>BOLL</button>
<!-- 实盘交易点交割单导入的买卖标记 -->
<button
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showTrades && trades.length ? 'border-amber-500 bg-amber-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
:title="trades.length ? `本股实盘成交 ${trades.length} 笔(交割单导入)` : '本股暂无实盘成交记录,点击导入交割单'"
@click="trades.length ? (showTrades = !showTrades) : openTradeImport()"
>交易点{{ trades.length ? ` ${trades.length}` : '' }}</button>
<button
type="button"
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
title="上传券商交割单,导入实盘买卖点"
@click="openTradeImport()"
>导入交割单</button>
<!-- MA 配置 -->
<div class="relative">
<button
@@ -478,13 +654,40 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
{{ f.label }}
</label>
</div>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-[#9BA3AE]">已选 {{ tooltipFields.length }}/{{ TOOLTIP_FIELDS.length }}首行日期固定</span>
<button type="button" class="text-xs text-blue-600 hover:underline" @click="resetTipFields">恢复默认</button>
</div>
</div>
</div>
<span class="ml-auto text-xs text-[#9BA3AE]">点击开关 · 拖动排序 · 调高度 · 右上工具栏画线</span>
<!-- 日期跳转输入 YYYYMMDD该日K线居中显示 -->
<div class="ml-auto flex items-center gap-1.5">
<span class="text-xs text-[#9BA3AE]">定位</span>
<div class="flex items-stretch">
<input
v-model="jumpInput"
type="text"
class="w-24 rounded-l-md border border-[#26272E] bg-[#16181D] px-2 py-1 font-mono text-[13px] text-[#E8EAED] outline-none transition-colors placeholder:text-[#7A818C]"
:class="jumpErr ? 'border-red-500' : 'focus:border-blue-500'"
placeholder="20200218"
maxlength="8"
inputmode="numeric"
title="输入日期YYYYMMDD跳转后该日K线居中显示"
@input="onJumpInput"
@keyup.enter="jumpToDate"
/>
<button
type="button"
class="rounded-r-md border border-l-0 border-[#26272E] bg-[#101014] px-2 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
title="跳转到该日期的日K回车亦可"
@click="jumpToDate"
>跳转</button>
</div>
<button
v-if="jumpTs != null"
type="button"
class="rounded-md border border-blue-500/60 bg-blue-500/10 px-2 py-1 font-mono text-xs text-blue-300 transition-colors hover:bg-blue-500/20"
title="清除日期锚点,回到最新行情"
@click="clearJump"
>{{ fmtDashDate(jumpTs) }} </button>
<span v-if="jumpErr" class="text-xs text-red-400">{{ jumpErr }}</span>
</div>
</div>
<!-- 图表 -->
@@ -496,6 +699,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-400">{{ error }}</div>
<DetailKLine
v-else-if="data && data.candles.length"
ref="klineRef"
:ticker="data.ts_code"
:candles="data.candles"
:indicators="data.indicators"
@@ -507,6 +711,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:show-boll="showBoll"
:timeframe="timeframe"
:tooltip-fields="tooltipFields"
:center-ts="jumpTs"
:trade-markers="tradeMarkers"
@center-miss="onCenterMiss"
/>
<div v-else class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">无数据</div>
</div>
@@ -577,5 +784,64 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</div>
</aside>
</div>
<!-- 交割单导入弹窗 -->
<div
v-if="showTradeImport"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
@click.self="showTradeImport = false"
>
<div class="w-full max-w-md rounded-lg border border-[#33353D] bg-[#16181D] p-4 shadow-2xl shadow-black/70">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-[#E8EAED]">导入交割单(实盘买卖点)</span>
<button
type="button"
class="rounded p-1 text-[#9BA3AE] transition-colors hover:bg-[#26272E] hover:text-white"
title="关闭 (Esc)"
@click="showTradeImport = false"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<p class="mt-2 text-xs leading-5 text-[#9BA3AE]">
上传券商导出的交割单(江海证券通达信版:「查询 → 交割单 → 输出」App/同花顺版:「交割单 → 导出/发送邮箱」)。
CSV / Excel / txt / HTML 均可编码与列名自动识别同日成交合并为一个标注B=买入、S=卖出、T=当日买卖都有),鼠标悬停字母可查看数量与均价。
</p>
<label
class="mt-3 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-4 py-6 text-center transition-colors"
:class="importing ? 'border-[#26272E] opacity-60' : 'border-[#3A3D46] hover:border-blue-500/60'"
>
<span class="text-[13px] text-[#C3C9D2]">{{ importing ? '解析导入中…' : '点击选择交割单文件' }}</span>
<span class="mt-1 text-xs text-[#7A818C]">支持 .csv / .txt / .xls / .xlsx / .html20MB 以内</span>
<input
type="file"
class="hidden"
accept=".csv,.txt,.xls,.xlsx,.htm,.html"
:disabled="importing"
@change="onTradeFile"
/>
</label>
<!-- 导入结果反馈 -->
<div v-if="importResult" class="mt-3 rounded-md border border-blue-500/30 bg-blue-500/10 p-2.5 text-xs leading-5 text-[#C3C9D2]">
<div>
新增 <span class="font-semibold text-blue-300">{{ importResult.inserted }}</span> 笔成交,覆盖 {{ importResult.stocks }} 只股票;
重复跳过 {{ importResult.skipped_dup }} 笔,其他跳过 {{ importResult.skipped_other }} 笔。
</div>
<div v-if="importResult.bad.length" class="mt-1 text-red-400">
{{ importResult.bad.join('') }}
</div>
</div>
<div v-if="importErr" class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 p-2.5 text-xs text-red-400">{{ importErr }}</div>
<!-- 危险操作:清空(作用于全部股票,不随当前股是否有成交隐藏入口) -->
<div class="mt-3 flex items-center justify-between border-t border-[#1E2026] pt-3">
<span class="text-xs text-[#7A818C]">清空全部股票的成交记录K 线买卖点将全部消失</span>
<button
type="button"
class="rounded-md border border-red-500/50 px-2.5 py-1 text-[13px] text-red-400 transition-colors hover:bg-red-500/15"
@click="onClearTrades"
>清空全部成交记录</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,207 @@
import { computed, ref, watchEffect } from 'vue';
import { defineStore } from 'pinia';
import { getPreferences, putPreferences } from '@/api/client';
import type { ChartLayoutPrefs, TooltipField } from '@/api/types';
export type PriceTone = 'red-up' | 'green-up';
export type PriceAdjust = 'bfq' | 'qfq' | 'hfq';
const STORAGE_KEY = 'stock.settings.priceTone';
const ADJUST_KEY = 'stock.settings.priceAdjust';
const LAYOUT_KEY = 'stock.settings.chartLayout';
const RED = '#FE354B';
const GREEN = '#1EBE72';
export const DEFAULT_MA_PERIODS = [5, 10, 20, 60];
export const DEFAULT_SUB_PANES = ['vol', 'macd', 'kdj'];
/** K线浮层指标目录展示顺序即目录顺序设置弹层与浮层渲染共用 */
export const TOOLTIP_FIELDS: { key: TooltipField; label: string }[] = [
{ key: 'open', label: '开盘价' },
{ key: 'high', label: '最高价' },
{ key: 'low', label: '最低价' },
{ key: 'close', label: '收盘价' },
{ key: 'diff', label: '涨跌' },
{ key: 'chg', label: '涨幅' },
{ key: 'amp', label: '振幅' },
{ key: 'vol', label: '总量' },
{ key: 'amount', label: '总额' },
{ key: 'turnover', label: '换手' },
];
const TIP_VALID = new Set(TOOLTIP_FIELDS.map((f) => f.key));
export const DEFAULT_TOOLTIP_FIELDS: TooltipField[] = TOOLTIP_FIELDS.map((f) => f.key);
/** 过滤为合法指标 key非数组返回 undefined调用方决定回退空数组合法=仅显示日期头 */
function normTipFields(v: unknown): TooltipField[] | undefined {
if (!Array.isArray(v)) return undefined;
return v.filter((k): k is TooltipField => typeof k === 'string' && TIP_VALID.has(k as TooltipField));
}
function loadTone(): PriceTone {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'red-up' || raw === 'green-up') return raw;
} catch { /* localStorage 不可用时用默认值 */ }
return 'red-up'; // A股默认红涨绿跌
}
function loadAdjust(): PriceAdjust {
try {
const raw = localStorage.getItem(ADJUST_KEY);
if (raw === 'bfq' || raw === 'qfq' || raw === 'hfq') return raw;
} catch { /* ignore */ }
return 'qfq';
}
function loadLayout(): ChartLayoutPrefs {
try {
const raw = localStorage.getItem(LAYOUT_KEY);
if (raw) {
const v = JSON.parse(raw) as ChartLayoutPrefs;
if (Array.isArray(v.maPeriods) && Array.isArray(v.subPanes)) {
return {
...v,
subHeights: v.subHeights ?? {},
tooltipFields: normTipFields(v.tooltipFields) ?? DEFAULT_TOOLTIP_FIELDS,
};
}
}
} catch { /* ignore */ }
return { maPeriods: DEFAULT_MA_PERIODS, subPanes: DEFAULT_SUB_PANES, subHeights: {}, tooltipFields: DEFAULT_TOOLTIP_FIELDS };
}
function saveLocal(key: string, value: string) {
try { localStorage.setItem(key, value); } catch { /* 忽略持久化失败 */ }
}
// 未成功推送到服务端的本地改动key 集合)。持久化到 localStorage
// 页面刷新后仍能让下次登录时“本地优先”,避免旧服务端存档覆盖离线期间的修改
const DIRTY_KEY = 'stock.settings.dirty';
function loadDirty(): Set<string> {
try {
const raw = localStorage.getItem(DIRTY_KEY);
const v = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : []);
} catch { return new Set(); }
}
/** 用户偏好localStorage 即时缓存 + 登录后与 user_preferences 表防抖同步 */
export const useSettingsStore = defineStore('settings', () => {
const priceTone = ref<PriceTone>(loadTone());
const upHex = computed(() => (priceTone.value === 'red-up' ? RED : GREEN));
const downHex = computed(() => (priceTone.value === 'red-up' ? GREEN : RED));
// 运行时覆盖 Tailwind 主题变量text-up / text-down 全站即时生效
watchEffect(() => {
const root = document.documentElement.style;
root.setProperty('--color-up', upHex.value);
root.setProperty('--color-down', downHex.value);
});
// ---------- 服务端同步(未登录时静默跳过) ----------
const synced = ref(false);
let pushTimer: ReturnType<typeof setTimeout> | null = null;
const pending: Record<string, unknown> = {};
const dirty = loadDirty();
function markDirty(keys: string[]) {
let changed = false;
for (const k of keys) if (!dirty.has(k)) { dirty.add(k); changed = true; }
if (changed) saveLocal(DIRTY_KEY, JSON.stringify([...dirty]));
}
function clearDirty(keys: string[]) {
let changed = false;
for (const k of keys) if (dirty.delete(k)) changed = true;
if (changed) saveLocal(DIRTY_KEY, JSON.stringify([...dirty]));
}
function schedulePush(key: string, value: unknown) {
pending[key] = value;
markDirty([key]); // 推送确认成功前视为脏,失败/离线期间不被旧档冲掉
if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(async () => {
const batch = { ...pending };
for (const k of Object.keys(batch)) delete pending[k];
try {
await putPreferences(batch);
clearDirty(Object.keys(batch));
} catch { /* 未登录/离线localStorage 已兜底dirty 保留待下次登录重推 */ }
}, 800);
}
/** 登出/会话失效时调用:允许下一次登录重新拉取该账号的偏好 */
function invalidateSync() {
synced.value = false;
}
/** 登录成功后调用:有未推送本地修改的 key 以本地为准并重推,其余采用服务端存档 */
async function syncFromServer() {
if (synced.value) return;
synced.value = true;
try {
const prefs = await getPreferences();
if (dirty.has('priceTone')) {
schedulePush('priceTone', priceTone.value);
} else if (typeof prefs.priceTone === 'string' && prefs.priceTone !== priceTone.value) {
priceTone.value = prefs.priceTone as PriceTone;
saveLocal(STORAGE_KEY, prefs.priceTone);
} else if (prefs.priceTone === undefined) {
schedulePush('priceTone', priceTone.value);
}
if (dirty.has('priceAdjust')) {
schedulePush('priceAdjust', priceAdjust.value);
} else if (typeof prefs.priceAdjust === 'string' && prefs.priceAdjust !== priceAdjust.value) {
priceAdjust.value = prefs.priceAdjust as PriceAdjust;
saveLocal(ADJUST_KEY, prefs.priceAdjust);
} else if (prefs.priceAdjust === undefined) {
schedulePush('priceAdjust', priceAdjust.value);
}
if (dirty.has('chartLayout')) {
schedulePush('chartLayout', chartLayout.value);
} else if (prefs.chartLayout && typeof prefs.chartLayout === 'object') {
const v = prefs.chartLayout as ChartLayoutPrefs;
if (Array.isArray(v.maPeriods) && Array.isArray(v.subPanes)) {
// 服务端存档早于浮层配置(无 tooltipFields时保留本地选择避免旧档案冲掉
const tip = normTipFields(v.tooltipFields) ?? chartLayout.value.tooltipFields;
chartLayout.value = { ...v, subHeights: v.subHeights ?? {}, tooltipFields: tip };
saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value));
}
} else {
schedulePush('chartLayout', chartLayout.value);
}
} catch { /* 未登录:仅本地 */ }
}
function setPriceTone(tone: PriceTone) {
priceTone.value = tone;
saveLocal(STORAGE_KEY, tone);
schedulePush('priceTone', tone);
}
// K线复权模式个股详情默认口径浮层内切换会回写此处
const priceAdjust = ref<PriceAdjust>(loadAdjust());
function setPriceAdjust(adj: PriceAdjust) {
priceAdjust.value = adj;
saveLocal(ADJUST_KEY, adj);
schedulePush('priceAdjust', adj);
}
// ---------- 看股页图表布局MA 周期 / 副图顺序 / 副图高度) ----------
const chartLayout = ref<ChartLayoutPrefs>(loadLayout());
function setChartLayout(patch: Partial<ChartLayoutPrefs>) {
chartLayout.value = { ...chartLayout.value, ...patch };
saveLocal(LAYOUT_KEY, JSON.stringify(chartLayout.value));
schedulePush('chartLayout', chartLayout.value);
}
return {
priceTone, upHex, downHex, setPriceTone,
priceAdjust, setPriceAdjust,
chartLayout, setChartLayout,
syncFromServer, invalidateSync,
};
});

View File

@@ -0,0 +1,412 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { addWatchlist, getStockFacets, getStocks, removeWatchlist } from '@/api/client';
import type { FacetItem, ScreenerItemOut, StockListItem } from '@/api/types';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined {
const v = route.query[key];
return typeof v === 'string' && v ? v : undefined;
}
const MARKETS = ['全部', '自选', '主板', '创业板', '科创板', '北交所'];
// 排序键后端白名单symbol/total_mv/circ_mv/pe_ttm/pb/turnover_rate
const SORT_KEYS = ['symbol', 'total_mv', 'circ_mv', 'pe_ttm', 'pb', 'turnover_rate'] as const;
type SortKey = (typeof SORT_KEYS)[number];
const search = ref(qStr('q') ?? '');
const market = ref(MARKETS.includes(qStr('market') ?? '') ? (qStr('market') as string) : '全部');
const industry = ref(qStr('industry') ?? '');
const area = ref(qStr('area') ?? '');
const industries = ref<FacetItem[]>([]);
const areas = ref<FacetItem[]>([]);
const pageSize = 100;
const page = ref(Math.max(1, parseInt(qStr('page') ?? '1', 10) || 1));
const sortParam = qStr('sort');
const sort = ref<SortKey>(SORT_KEYS.includes((sortParam ?? 'symbol') as SortKey) ? ((sortParam ?? 'symbol') as SortKey) : 'symbol');
const order = ref<'asc' | 'desc'>(qStr('order') === 'desc' ? 'desc' : 'asc');
// 列表状态
const items = ref<StockListItem[]>([]);
const total = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
// 详情浮层(当前股记录在 ?code=,刷新后浮层自动重开)
const previewCode = ref<string | null>(qStr('code') ?? null);
// StockDetailOverlay 需要 ScreenerItemOut 形状;行情字段缺失时它内部有兜底
const overlayItems = computed<ScreenerItemOut[]>(() =>
items.value.map((it) => ({
ts_code: it.ts_code,
name: it.name,
close: it.close ?? null,
pct_chg: it.pct_chg ?? null,
total_mv: null,
circ_mv: null,
pe_ttm: null,
pb: null,
turnover_rate: null,
indicators: {},
})),
);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
// ---------- 加载(搜索防抖) ----------
let fetchToken = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() {
const token = ++fetchToken;
loading.value = true;
error.value = null;
try {
const res = await getStocks({
search: search.value.trim(),
// 「自选」不是 stock_basic.market 的值,走 watched_only
market: market.value === '全部' || market.value === '自选' ? '' : market.value,
watched_only: market.value === '自选',
industry: industry.value,
area: area.value,
sort: sort.value,
order: order.value,
limit: pageSize,
offset: (page.value - 1) * pageSize,
});
if (token === fetchToken) {
items.value = res.items;
total.value = res.total;
}
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
watch(search, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
page.value = 1;
load();
}, 300);
});
// 筛选/排序变化回到第一页page 的 watch 会再触发 load翻页直接加载
watch([market, industry, area, sort, order], () => {
if (page.value !== 1) page.value = 1;
else load();
});
watch(page, () => load());
load();
getStockFacets()
.then((f) => {
industries.value = f.industries;
areas.value = f.areas;
})
.catch(() => { /* 筛选项加载失败不阻塞列表 */ });
onBeforeUnmount(() => clearTimeout(debounceTimer));
function pctClass(v: number | null | undefined): string {
if (v == null) return 'text-[#9BA3AE]';
return v > 0 ? 'text-up' : v < 0 ? 'text-down' : 'text-[#A8AFB8]';
}
function fmtPct(v: number | null | undefined): string {
if (v == null) return '--';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '--';
return v.slice(0, 10);
}
function fmtNum(v: number | null | undefined, digits = 2): string {
if (v == null) return '--';
return v.toFixed(digits);
}
function fmtYi(v: number | null | undefined): string {
if (v == null) return '--';
return v >= 100 ? Math.round(v).toLocaleString() : v.toFixed(2);
}
function fmtTurnover(v: number | null | undefined): string {
if (v == null) return '--';
return `${v.toFixed(2)}%`;
}
// ---------- 列排序(后端白名单键) ----------
function toggleSort(key: SortKey) {
if (sort.value === key) {
order.value = order.value === 'asc' ? 'desc' : 'asc';
} else {
sort.value = key;
// 代码列默认升序;其余(市值/估值/换手)默认降序——先看最大/最热
order.value = key === 'symbol' ? 'asc' : 'desc';
}
}
// PE-TTM 分档着色≤15 冷绿、15-30 中性、30-60 琥珀、>60 红;亏损/无数据灰
function peClass(v: number | null | undefined): string {
if (v == null || v <= 0) return 'text-[#9BA3AE]';
if (v <= 15) return 'text-emerald-400';
if (v <= 30) return 'text-[#A8AFB8]';
if (v <= 60) return 'text-amber-400';
return 'text-red-400';
}
function go(delta: number) {
const next = page.value + delta;
if (next >= 1 && next <= totalPages.value) page.value = next;
}
// ---------- 自选股星标(服务端为唯一事实源,本地行内即时翻转) ----------
const starBusy = ref('');
async function toggleStar(it: StockListItem) {
if (starBusy.value === it.ts_code) return;
starBusy.value = it.ts_code;
const wasWatched = it.watched;
it.watched = !wasWatched; // 乐观更新
try {
const list = wasWatched ? await removeWatchlist(it.ts_code) : await addWatchlist(it.ts_code);
const set = new Set(list);
for (const row of items.value) row.watched = set.has(row.ts_code);
} catch {
it.watched = wasWatched; // 回滚
} finally {
starBusy.value = '';
}
}
// 详情浮层里增删自选后,刷新当前页星标
function onWatchedChange() {
load();
}
// ---------- 路由同步:状态 → ?q/&market/…replace 不产生历史记录) ----------
function buildQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (search.value.trim()) q.q = search.value.trim();
if (market.value !== '全部') q.market = market.value;
if (industry.value) q.industry = industry.value;
if (area.value) q.area = area.value;
if (sort.value !== 'symbol') q.sort = sort.value;
if (order.value !== 'asc') q.order = order.value;
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) {
const query = buildQuery();
// 与当前 URL 一致就跳过,避免 state→route→state 回声
if (JSON.stringify(query) === JSON.stringify(route.query)) return;
selfNav++;
const done = () => { selfNav--; };
void (push ? router.push({ query }) : router.replace({ query })).then(done, done);
}
// 列表状态变化(含搜索防抖外的输入)随手回写 URL翻页/筛选也带着当前 ?code
watch([search, market, industry, area, 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') : '全部';
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= ----------
function openStock(code: string) {
previewCode.value = code;
syncRoute(true); // push浏览器返回键 = 关闭浮层
}
function onOverlayChange(code: string) {
previewCode.value = code; // 浮层内切股(键盘/侧栏)同步到路由
syncRoute();
}
function closeOverlay() {
previewCode.value = null;
syncRoute();
}
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold text-[#E8EAED]">全部股票</h1>
<span class="text-[13px] text-[#9BA3AE]"> {{ total.toLocaleString() }} · 点击行查看 K 线详情</span>
<div class="relative ml-auto">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9BA3AE]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input
v-model="search"
type="text"
placeholder="搜索代码 / 名称"
class="w-56 rounded-md border border-[#33353D] bg-[#16181D] py-2 pl-9 pr-3 text-sm text-[#E8EAED] outline-none transition placeholder:text-[#7A818C] focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30"
/>
</div>
<select v-model="market" class="ipt !w-auto !py-1.5 text-[13px]" title="按板块筛选">
<option v-for="m in MARKETS" :key="m" :value="m">{{ m }}</option>
</select>
<select v-model="industry" class="ipt !w-auto !py-1.5 text-[13px]" title="按行业筛选">
<option value="">全部行业</option>
<option v-for="i in industries" :key="i.name" :value="i.name">{{ i.name }}{{ i.count }}</option>
</select>
<select v-model="area" class="ipt !w-auto !py-1.5 text-[13px]" title="按地域筛选">
<option value="">全部地域</option>
<option v-for="a in areas" :key="a.name" :value="a.name">{{ a.name }}{{ a.count }}</option>
</select>
</div>
<div v-if="error" class="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/15 px-4 py-3 text-sm text-red-400">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ error }}
</div>
<div class="overflow-hidden rounded-xl border border-[#26272E] bg-[#101014]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#1E2026] text-left text-[13px] text-[#A8AFB8]">
<th class="w-10 px-2 py-3 font-medium" title="自选"></th>
<th class="px-4 py-3 font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'symbol' ? 'text-[#E8EAED]' : ''" @click="toggleSort('symbol')">
代码<span class="text-[10px] leading-none" :class="sort === 'symbol' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'symbol' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">行业</th>
<th class="px-4 py-3 font-medium">市场</th>
<th class="px-4 py-3 text-right font-medium">最新价</th>
<th class="px-4 py-3 text-right font-medium">涨跌幅</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'total_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('total_mv')">
总市值<span class="text-[10px] leading-none" :class="sort === 'total_mv' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'total_mv' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'circ_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('circ_mv')">
流通市值<span class="text-[10px] leading-none" :class="sort === 'circ_mv' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'circ_mv' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="≤15 绿 · 15-30 灰 · 30-60 黄 · >60 红;亏损/无数据为空">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'pe_ttm' ? 'text-[#E8EAED]' : ''" @click="toggleSort('pe_ttm')">
市盈率TTM<span class="text-[10px] leading-none" :class="sort === 'pe_ttm' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'pe_ttm' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'pb' ? 'text-[#E8EAED]' : ''" @click="toggleSort('pb')">
市净率<span class="text-[10px] leading-none" :class="sort === 'pb' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'pb' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'turnover_rate' ? 'text-[#E8EAED]' : ''" @click="toggleSort('turnover_rate')">
换手率<span class="text-[10px] leading-none" :class="sort === 'turnover_rate' ? 'text-blue-400' : 'text-[#4A4D55]'">{{ sort === 'turnover_rate' ? (order === 'asc' ? '▲' : '▼') : '⇅' }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">数据截至</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && items.length === 0">
<td colspan="13" class="px-4 py-16 text-center text-[#9BA3AE]">加载中</td>
</tr>
<tr
v-for="it in items"
:key="it.ts_code"
class="cursor-pointer border-b border-[#1E2026] transition hover:bg-blue-500/15"
@click="openStock(it.ts_code)"
>
<td class="px-2 py-2.5 text-center" @click.stop>
<button
type="button"
class="rounded p-0.5 transition-colors disabled:opacity-50"
:class="it.watched ? 'text-amber-500 hover:text-amber-600' : 'text-[#C3C9D2] hover:text-amber-400'"
:title="it.watched ? '移出自选' : '加入自选'"
:disabled="starBusy === it.ts_code"
@click="toggleStar(it)"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" :fill="it.watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[#E8EAED]">{{ it.symbol }}</td>
<td class="px-4 py-2.5 font-medium text-[#E8EAED]">{{ it.name }}</td>
<td class="px-4 py-2.5 text-[#A8AFB8]">{{ it.industry || '--' }}</td>
<td class="px-4 py-2.5">
<span class="rounded bg-[#26272E] px-1.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ it.market || '--' }}</span>
</td>
<td class="px-4 py-2.5 text-right font-mono text-sm font-medium tabular-nums" :class="pctClass(it.pct_chg)">{{ it.close?.toFixed(2) ?? '--' }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums" :class="pctClass(it.pct_chg)">{{ fmtPct(it.pct_chg) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.total_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.circ_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums" :class="peClass(it.pe_ttm)">{{ fmtNum(it.pe_ttm) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtNum(it.pb) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtTurnover(it.turnover_rate) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm text-[#9BA3AE]">{{ fmtDate(it.last_ts) }}</td>
</tr>
<tr v-if="!loading && items.length === 0">
<td colspan="13" class="px-4 py-16 text-center text-[#9BA3AE]">没有匹配的股票</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-[#1E2026] px-4 py-3 text-[13px] text-[#A8AFB8]">
<span v-if="loading">加载中</span>
<span v-else> {{ page }} / {{ totalPages }} </span>
<div class="flex gap-2">
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page <= 1 || loading"
@click="go(-1)"
>
上一页
</button>
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page >= totalPages || loading"
@click="go(1)"
>
下一页
</button>
</div>
</div>
</div>
<!-- 全屏个股详情与选股页同款当前股记录在 ?code= -->
<StockDetailOverlay
v-if="previewCode && overlayItems.length"
:items="overlayItems"
:initial="previewCode"
@close="closeOverlay"
@change="onOverlayChange"
@watched-change="onWatchedChange"
/>
</div>
</template>