看股功能更新
This commit is contained in:
@@ -3,6 +3,9 @@ TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
|
|||||||
DATA_ADJUST=qfq
|
DATA_ADJUST=qfq
|
||||||
DATA_DEFAULT_START=20200101
|
DATA_DEFAULT_START=20200101
|
||||||
|
|
||||||
|
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
|
||||||
|
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
|
||||||
|
|
||||||
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
||||||
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
||||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ LLM_MODEL=glm-5.2
|
|||||||
# TRANSFER_FEE_RATE=0.00001 # 过户费 0.001%,沪深双边
|
# TRANSFER_FEE_RATE=0.00001 # 过户费 0.001%,沪深双边
|
||||||
# COMMISSION_RATE=0.0001 # 佣金 万1
|
# COMMISSION_RATE=0.0001 # 佣金 万1
|
||||||
# COMMISSION_MIN=5.0 # 最低 5 元
|
# COMMISSION_MIN=5.0 # 最低 5 元
|
||||||
|
|
||||||
|
# ---- Redis 读缓存(可选;股票列表/筛选项提速)----
|
||||||
|
# 留空 = 不缓存,直查数据库;连接失败自动降级,不影响接口可用性
|
||||||
|
# REDIS_URL=redis://default:password@127.0.0.1:6379
|
||||||
|
# STOCKS_CACHE_TTL=300 # 股票列表缓存秒数
|
||||||
|
# FACETS_CACHE_TTL=3600 # 行业/地域筛选项缓存秒数
|
||||||
|
|||||||
36
backend/alembic/versions/20260815_01_add_adj_factor.py
Normal file
36
backend/alembic/versions/20260815_01_add_adj_factor.py
Normal 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")
|
||||||
69
backend/alembic/versions/20260815_02_user_data_tables.py
Normal file
69
backend/alembic/versions/20260815_02_user_data_tables.py
Normal 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")
|
||||||
@@ -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_rate(2000 年起)
|
||||||
|
均为可空列——历史回补前为 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")
|
||||||
51
backend/alembic/versions/20260815_04_user_trades.py
Normal file
51
backend/alembic/versions/20260815_04_user_trades.py
Normal 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")
|
||||||
@@ -15,11 +15,13 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import delete, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql.elements import TextClause
|
||||||
|
|
||||||
from .backtest.engine import BacktestConfig, run_backtest
|
from .backtest.engine import BacktestConfig, run_backtest
|
||||||
|
from . import cache
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
from .backtest.events import EventEngineError, run_event_backtest
|
from .backtest.events import EventEngineError, run_event_backtest
|
||||||
from .backtest.strategies import build_strategy
|
from .backtest.strategies import build_strategy
|
||||||
@@ -30,6 +32,7 @@ from .data.symbols import plain_code
|
|||||||
from .db import get_session
|
from .db import get_session
|
||||||
from .domain import Bar
|
from .domain import Bar
|
||||||
from . import indicators as ind
|
from . import indicators as ind
|
||||||
|
from .trades import parse_statement
|
||||||
from .models import (
|
from .models import (
|
||||||
AdjFactor,
|
AdjFactor,
|
||||||
BacktestRun,
|
BacktestRun,
|
||||||
@@ -38,6 +41,7 @@ from .models import (
|
|||||||
ScreenerQuery,
|
ScreenerQuery,
|
||||||
StockBasic,
|
StockBasic,
|
||||||
UserPreference,
|
UserPreference,
|
||||||
|
UserTrade,
|
||||||
WatchlistItem,
|
WatchlistItem,
|
||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
@@ -66,6 +70,9 @@ from .schemas import (
|
|||||||
FacetItemOut,
|
FacetItemOut,
|
||||||
SyncRequest,
|
SyncRequest,
|
||||||
SyncResponse,
|
SyncResponse,
|
||||||
|
TradesClearResponse,
|
||||||
|
TradesImportResponse,
|
||||||
|
UserTradeOut,
|
||||||
WatchlistOp,
|
WatchlistOp,
|
||||||
)
|
)
|
||||||
from .screener import engine, market_sync
|
from .screener import engine, market_sync
|
||||||
@@ -163,37 +170,63 @@ async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_sessio
|
|||||||
|
|
||||||
|
|
||||||
# ---------- 股票列表(全市场浏览) ----------
|
# ---------- 股票列表(全市场浏览) ----------
|
||||||
_STOCKS_SQL = text("""
|
# 过滤/排序/分页在 stock_basic+watchlist+daily_snapshot 上完成(快照按最新交易日
|
||||||
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
|
# 走唯一索引 join,便宜),再对「本页」≤limit 只股票补最新价/昨收(LATERAL 扫
|
||||||
c.close AS close, p.close AS prev_close, c.ts AS last_ts, cnt.n AS bar_count,
|
# candles,贵)——旧写法对全市场 ~5000 只逐个算,每页都白算 50 倍的行情量。
|
||||||
CASE WHEN c.close IS NOT NULL AND p.close IS NOT NULL AND p.close <> 0
|
# 排序列白名单(键→CTE 内表达式);order_by 由白名单拼接进模板,不接收用户原文。
|
||||||
THEN round(((c.close / p.close - 1) * 100)::numeric, 2) END AS pct_chg,
|
_STOCKS_SORTS = {
|
||||||
(w.id IS NOT NULL) AS watched
|
"symbol": "sb.symbol",
|
||||||
FROM stock_basic sb
|
"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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT close, ts FROM candles
|
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
|
ORDER BY ts DESC LIMIT 1
|
||||||
) c ON true
|
) c ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT close FROM candles
|
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
|
ORDER BY ts DESC LIMIT 1
|
||||||
) p ON c.ts IS NOT NULL
|
) prev 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
|
def _stocks_sql(sort: str, order: str) -> TextClause:
|
||||||
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
|
col = _STOCKS_SORTS.get(sort, _STOCKS_SORTS["symbol"])
|
||||||
WHERE sb.list_status = 'L'
|
direction = "DESC" if order == "desc" else "ASC"
|
||||||
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
|
nulls = " NULLS LAST" if col != "sb.symbol" else "" # 快照缺失/亏损无 PE 的排最后
|
||||||
AND (:market = '' OR sb.market = :market)
|
return text(_STOCKS_SQL_TMPL.format(order_by=f"{col} {direction}{nulls}"))
|
||||||
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
|
|
||||||
""")
|
|
||||||
|
|
||||||
_STOCKS_COUNT_SQL = text("""
|
_STOCKS_COUNT_SQL = text("""
|
||||||
SELECT count(*) FROM stock_basic sb
|
SELECT count(*) FROM stock_basic sb
|
||||||
@@ -214,16 +247,32 @@ async def list_stocks(
|
|||||||
industry: str = "",
|
industry: str = "",
|
||||||
area: str = "",
|
area: str = "",
|
||||||
watched_only: bool = False,
|
watched_only: bool = False,
|
||||||
|
sort: str = "symbol",
|
||||||
|
order: str = "asc",
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
) -> StockListResponse:
|
) -> StockListResponse:
|
||||||
"""全市场股票列表:stock_basic 基本信息 + candles 最新行情(本地缓存,无缓存则行情列为空)。
|
"""全市场股票列表:stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标
|
||||||
自选股(watchlist_items)排最前;watched_only=true 只看自选。"""
|
(换手率/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()
|
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))
|
limit = max(1, min(limit, 500))
|
||||||
offset = max(0, offset)
|
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 = {
|
params = {
|
||||||
"search": search,
|
"search": search,
|
||||||
"psearch": f"%{search}%",
|
"psearch": f"%{search}%",
|
||||||
@@ -236,13 +285,18 @@ async def list_stocks(
|
|||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
|
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
|
||||||
rows = (await session.execute(_STOCKS_SQL, params)).mappings().all()
|
rows = (await session.execute(_stocks_sql(sort, order), params)).mappings().all()
|
||||||
return StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
|
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)
|
@router.get("/stocks/facets", response_model=StockFacetsResponse)
|
||||||
async def stock_facets(session: AsyncSession = Depends(get_session)) -> 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 = (
|
industries = (
|
||||||
await session.execute(text("""
|
await session.execute(text("""
|
||||||
SELECT industry AS name, count(*) AS n FROM stock_basic
|
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
|
GROUP BY area ORDER BY n DESC
|
||||||
"""))
|
"""))
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
return StockFacetsResponse(
|
resp = StockFacetsResponse(
|
||||||
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
|
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
|
||||||
areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas],
|
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)
|
@router.post("/backtest", response_model=BacktestResponse)
|
||||||
@@ -551,6 +607,7 @@ async def add_watchlist(
|
|||||||
if exists is None:
|
if exists is None:
|
||||||
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
|
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
|
||||||
return await get_watchlist(session=session, user=user)
|
return await get_watchlist(session=session, user=user)
|
||||||
|
|
||||||
|
|
||||||
@@ -565,9 +622,125 @@ async def remove_watchlist(
|
|||||||
{"u": user.id, "c": ts_code},
|
{"u": user.id, "c": ts_code},
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await cache.bump_version(f"watchlist:{user.id}") # 作废该用户的股票列表缓存
|
||||||
return await get_watchlist(session=session, user=user)
|
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)
|
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
|
||||||
async def screener_sync_start(
|
async def screener_sync_start(
|
||||||
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
|
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
|
||||||
|
|||||||
265
backend/app/backtest/events.py
Normal file
265
backend/app/backtest/events.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""事件回测引擎:入场条件命中 -> 次日买入 -> 持有 N 日 -> 全市场汇总统计。
|
||||||
|
|
||||||
|
数据口径:
|
||||||
|
- 行情底座是 candles(TDX 全量导入,不复权),全历史可用;
|
||||||
|
- 指标计算用不复权价(与选股/看盘口径一致: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 lookback)AND。"""
|
||||||
|
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
104
backend/app/cache.py
Normal 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)。版本号参与缓存 key:INCR 后旧 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
|
||||||
@@ -26,6 +26,11 @@ class Settings(BaseSettings):
|
|||||||
data_adjust: str = "qfq" # 复权:qfq 前复权 / hfq 后复权 / "" 不复权
|
data_adjust: str = "qfq" # 复权:qfq 前复权 / hfq 后复权 / "" 不复权
|
||||||
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年)
|
data_default_start: str = "20200101" # 默认拉取起点(约近 5 年)
|
||||||
|
|
||||||
|
# ---- Redis 读缓存(股票列表/筛选项等读多写少接口;留空 = 不缓存,直查数据库)----
|
||||||
|
redis_url: str = ""
|
||||||
|
stocks_cache_ttl: int = 300 # 股票列表缓存秒数(行情列允许最多滞后这么多秒)
|
||||||
|
facets_cache_ttl: int = 3600 # 行业/地域筛选项缓存秒数(stock_basic 很少变)
|
||||||
|
|
||||||
# ---- LLM(智能选股的自然语言解析;DeepSeek,OpenAI 兼容协议,可换任意兼容网关)----
|
# ---- LLM(智能选股的自然语言解析;DeepSeek,OpenAI 兼容协议,可换任意兼容网关)----
|
||||||
llm_base_url: str = "https://api.deepseek.com"
|
llm_base_url: str = "https://api.deepseek.com"
|
||||||
llm_api_key: str = "" # 留空则智能选股不可用(其余功能不受影响)
|
llm_api_key: str = "" # 留空则智能选股不可用(其余功能不受影响)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from . import cache
|
||||||
from .api import router
|
from .api import router
|
||||||
from .auth_api import router as auth_router
|
from .auth_api import router as auth_router
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -17,6 +18,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await conn.execute(text("SELECT 1"))
|
await conn.execute(text("SELECT 1"))
|
||||||
yield
|
yield
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
await cache.aclose() # 释放 Redis 连接池(未启用时是 no-op)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
|
|||||||
智能选股三表(stock_basic / market_daily / daily_snapshot)与回测 candles(qfq)
|
智能选股三表(stock_basic / market_daily / daily_snapshot)与回测 candles(qfq)
|
||||||
完全隔离:选股用未复权日线按 trade_date 全市场批量落地,避免污染回测复权缓存。
|
完全隔离:选股用未复权日线按 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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .db import Base
|
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):
|
class ScreenerQuery(Base):
|
||||||
"""自然语言选股提问历史(文本 + 解析出的条件,便于一键重跑)。"""
|
"""自然语言选股提问历史(文本 + 解析出的条件,便于一键重跑)。"""
|
||||||
__tablename__ = "screener_queries"
|
__tablename__ = "screener_queries"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -298,7 +298,11 @@ class StockListItemOut(BaseModel):
|
|||||||
prev_close: float | None = None
|
prev_close: float | None = None
|
||||||
pct_chg: float | None = None # 最新两根日线计算
|
pct_chg: float | None = None # 最新两根日线计算
|
||||||
last_ts: datetime | 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 # 是否自选(当前用户)
|
watched: bool = False # 是否自选(当前用户)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,3 +347,29 @@ class ScreenerQueryOut(BaseModel):
|
|||||||
|
|
||||||
class ScreenerQueryListResponse(BaseModel):
|
class ScreenerQueryListResponse(BaseModel):
|
||||||
items: list[ScreenerQueryOut]
|
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
329
backend/app/trades.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""交割单解析(券商导出的成交流水 → 结构化买卖记录)。
|
||||||
|
|
||||||
|
支持三类导出物(按内容嗅探,不信任扩展名):
|
||||||
|
- CSV/制表符文本(utf-8-sig / gbk / gb18030 自动探测)
|
||||||
|
- Excel .xlsx(openpyxl;很多券商导出的 .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~2064),openpyxl 读无日期格式的单元格时会给出
|
||||||
|
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:])
|
||||||
@@ -16,6 +16,9 @@ dependencies = [
|
|||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"alembic>=1.19.1",
|
"alembic>=1.19.1",
|
||||||
|
"redis>=8.1.0",
|
||||||
|
"python-multipart>=0.0.32",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
122
backend/scripts/backfill_adj_factor.py
Normal file
122
backend/scripts/backfill_adj_factor.py
Normal 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))
|
||||||
162
backend/scripts/backfill_turnover.py
Normal file
162
backend/scripts/backfill_turnover.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""全量回补换手率(candles.turnover,单位 %)。
|
||||||
|
|
||||||
|
用法(在 backend 目录下):
|
||||||
|
uv run python scripts/backfill_turnover.py # 从 2000-01-01(daily_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))
|
||||||
160
backend/scripts/import_tdx_day.py
Normal file
160
backend/scripts/import_tdx_day.py
Normal 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="不清空任何行,纯 upsert(amount 回补模式)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
asyncio.run(main(Path(a.root), a.symbols or None, a.no_clear))
|
||||||
113
backend/scripts/test_trades_parser.py
Normal file
113
backend/scripts/test_trades_parser.py
Normal 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) xlsx(openpyxl 内存构造) ----------
|
||||||
|
import io # noqa: E402
|
||||||
|
from openpyxl import Workbook # noqa: E402
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(["对账单", None, None])
|
||||||
|
ws.append(["成交日期", "业务名称", "证券代码", "证券名称", "成交均价", "成交股数", "成交金额", "佣金", "过户费"])
|
||||||
|
from datetime import datetime as dt # noqa: E402
|
||||||
|
ws.append([dt(2024, 2, 28, 14, 35, 0), "证券买入", "688981", "中芯国际", 52.80, 200, 10560.00, 2.50, 1.06])
|
||||||
|
ws.append([dt(2024, 3, 1, 9, 31, 0), "证券卖出", "688981", "中芯国际", 54.10, 200, 10820.00, 2.50, 1.06])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
r5 = parse_statement(buf.getvalue(), "sm.xlsx")
|
||||||
|
check("xlsx: 2 笔成交", len(r5.trades) == 2, f"got {len(r5.trades)}")
|
||||||
|
check("xlsx: datetime 日期", r5.trades[0].trade_date.isoformat() == "2024-02-28")
|
||||||
|
check("xlsx: 科创板后缀", r5.trades[0].ts_code == "688981.SH", r5.trades[0].ts_code)
|
||||||
|
check("xlsx: 佣金+过户费", abs(r5.trades[0].fee - 3.56) < 1e-9, f"fee={r5.trades[0].fee}")
|
||||||
|
|
||||||
|
# ---------- 6) 错误分支 ----------
|
||||||
|
from fastapi import HTTPException # noqa: E402
|
||||||
|
try:
|
||||||
|
parse_statement("随便一串不是交割单的文字,1,2,3".encode("utf-8"), "x.csv")
|
||||||
|
check("garbage: 应 422", False)
|
||||||
|
except HTTPException as e:
|
||||||
|
check("garbage: 422", e.status_code == 422)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if FAIL:
|
||||||
|
print(f"FAIL {len(FAIL)}: {FAIL}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS: 交割单解析器全部用例通过")
|
||||||
45
backend/uv.lock
generated
45
backend/uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.141.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pandas"
|
name = "pandas"
|
||||||
version = "3.0.5"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.34.2"
|
version = "2.34.2"
|
||||||
@@ -1075,9 +1114,12 @@ dependencies = [
|
|||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
|
{ name = "redis" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "tushare" },
|
{ name = "tushare" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -1091,9 +1133,12 @@ requires-dist = [
|
|||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "numpy", specifier = ">=1.26" },
|
{ name = "numpy", specifier = ">=1.26" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
{ name = "pandas", specifier = ">=2.2" },
|
{ name = "pandas", specifier = ">=2.2" },
|
||||||
{ name = "pydantic", specifier = ">=2.7" },
|
{ name = "pydantic", specifier = ">=2.7" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.3" },
|
{ 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 = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "tushare", specifier = ">=1.4" },
|
{ name = "tushare", specifier = ">=1.4" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
|
|||||||
693
backend_run.log
Normal file
693
backend_run.log
Normal 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 ====
|
||||||
|
[1m[31merror[39m[0m[1m:[0m Failed to spawn: `uvicorn`
|
||||||
|
[1m[31mCaused by[39m[0m: 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
|
||||||
@@ -18,6 +18,9 @@ import type {
|
|||||||
SyncRequest,
|
SyncRequest,
|
||||||
SyncResponse,
|
SyncResponse,
|
||||||
Timeframe,
|
Timeframe,
|
||||||
|
TradesClearResponse,
|
||||||
|
TradesImportResponse,
|
||||||
|
UserTrade,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// dev 用 Vite 代理(/api -> :8000);生产构建设 VITE_API_BASE 指向后端地址。
|
// 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();
|
const body = await res.text();
|
||||||
if (!body) return fallback;
|
if (!body) return fallback;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(body) as { detail?: string };
|
const data = JSON.parse(body) as { detail?: unknown };
|
||||||
return data.detail || fallback;
|
// 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 {
|
} catch {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,9 @@ async function apiFetch(path: string, init: RequestInit = {}): Promise<Response>
|
|||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
// 仅 JSON(字符串 body)手工设 Content-Type;FormData 必须留给浏览器生成
|
||||||
|
// multipart 边界,手工设置会导致后端解析失败 422
|
||||||
|
...(typeof init.body === 'string' ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...init.headers,
|
...init.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -158,6 +166,8 @@ export async function getStocks(params: {
|
|||||||
industry?: string;
|
industry?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
watched_only?: boolean;
|
watched_only?: boolean;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<StockListResponse> {
|
}): Promise<StockListResponse> {
|
||||||
@@ -167,6 +177,8 @@ export async function getStocks(params: {
|
|||||||
if (params.industry) q.set('industry', params.industry);
|
if (params.industry) q.set('industry', params.industry);
|
||||||
if (params.area) q.set('area', params.area);
|
if (params.area) q.set('area', params.area);
|
||||||
if (params.watched_only) q.set('watched_only', 'true');
|
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('limit', String(params.limit ?? 100));
|
||||||
q.set('offset', String(params.offset ?? 0));
|
q.set('offset', String(params.offset ?? 0));
|
||||||
const res = await apiFetch(`/api/stocks?${q.toString()}`);
|
const res = await apiFetch(`/api/stocks?${q.toString()}`);
|
||||||
@@ -224,3 +236,27 @@ export async function deleteScreenerQuery(id: number): Promise<void> {
|
|||||||
const res = await apiFetch(`/api/screener/queries/${id}`, { method: 'DELETE' });
|
const res = await apiFetch(`/api/screener/queries/${id}`, { method: 'DELETE' });
|
||||||
if (!res.ok && res.status !== 401) throw new ApiError(`删除失败 (HTTP ${res.status})`, res.status);
|
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-Type,FormData 需自带 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,7 +211,11 @@ export interface StockListItem {
|
|||||||
prev_close?: number | null;
|
prev_close?: number | null;
|
||||||
pct_chg?: number | null;
|
pct_chg?: number | null;
|
||||||
last_ts?: string | 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;
|
watched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +261,32 @@ export interface ScreenerQueryItem {
|
|||||||
created_at: string;
|
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 {
|
export interface EventBacktestSpec {
|
||||||
entry: ScreenConditions;
|
entry: ScreenConditions;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
dispose, init, registerIndicator, registerOverlay,
|
dispose, init, registerIndicator, registerOverlay,
|
||||||
type Chart, type KLineData, type Point,
|
type Chart, type KLineData, type OverlayCreate, type OverlayTemplate, type Point,
|
||||||
} from 'klinecharts';
|
} from 'klinecharts';
|
||||||
// 官方画线扩展(preview.klinecharts.com 同款工具集);rect/circle 沿用 v10 内置版,不注册扩展的重名模板
|
// 官方画线扩展(preview.klinecharts.com 同款工具集);rect/circle 沿用 v10 内置版,不注册扩展的重名模板
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,50 @@ for (const t of [
|
|||||||
registerOverlay(t);
|
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<{
|
const props = defineProps<{
|
||||||
ticker: string;
|
ticker: string;
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
@@ -45,6 +89,17 @@ const props = defineProps<{
|
|||||||
timeframe: string;
|
timeframe: string;
|
||||||
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
|
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
|
||||||
tooltipFields?: TooltipField[];
|
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 跟随设置中的涨跌配色
|
// A股语义色(黑底高对比);UP/DOWN 跟随设置中的涨跌配色
|
||||||
@@ -246,7 +301,11 @@ function darkStyles() {
|
|||||||
horizontal: { text: { backgroundColor: '#333A45' } },
|
horizontal: { text: { backgroundColor: '#333A45' } },
|
||||||
vertical: { 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 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);
|
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 TipRow { key: string; label: string; text: string; tone: '' | 'up' | 'down' }
|
||||||
interface HoverInfo {
|
interface HoverInfo {
|
||||||
@@ -426,6 +502,113 @@ function pickTool(key: string) {
|
|||||||
function clearOverlays() {
|
function clearOverlays() {
|
||||||
chart?.removeOverlay();
|
chart?.removeOverlay();
|
||||||
activeTool.value = '';
|
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 会默认 removeOverlay(lock 只拦左键按下),标记被悄悄删掉——显式吞掉
|
||||||
|
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() {
|
function build() {
|
||||||
@@ -464,12 +647,20 @@ function build() {
|
|||||||
const start = allData.length - served - take;
|
const start = allData.length - served - take;
|
||||||
served += take;
|
served += take;
|
||||||
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
|
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
|
||||||
|
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
|
||||||
};
|
};
|
||||||
const answerEmpty = () => callback([], { forward: false, backward: false });
|
const answerEmpty = () => callback([], { forward: false, backward: false });
|
||||||
if (type === 'init') {
|
if (type === 'init') {
|
||||||
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
|
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页。
|
||||||
served = Math.min(INIT_BARS, allData.length);
|
// 有跳转锚点时把 serve 左扩到包含锚点(锚点落在窗口前 1/2 处),仍保持
|
||||||
callback(allData.slice(allData.length - served), { forward: canBack(), backward: false });
|
// [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);
|
maybePrefetch(myEpoch);
|
||||||
} else if (type === 'forward') {
|
} else if (type === 'forward') {
|
||||||
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
|
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
|
||||||
@@ -505,38 +696,62 @@ function build() {
|
|||||||
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
|
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
|
||||||
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', 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 subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
|
||||||
const total = container.value.clientHeight || 560;
|
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) {
|
for (const key of props.subPanes) {
|
||||||
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||||
ch.createIndicator(name);
|
ch.createIndicator(name);
|
||||||
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
|
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);
|
bindCrosshair(ch);
|
||||||
|
// 分隔条拖拽调高:拖动结束(250ms 无新事件)后把各副图实际高度持久化;
|
||||||
|
// 期间图表已重建(epoch 变化)则丢弃,新图表按存档布局
|
||||||
|
ch.subscribeAction('onPaneDrag', () => {
|
||||||
|
if (myEpoch !== epoch) return;
|
||||||
|
if (subHTimer) clearTimeout(subHTimer);
|
||||||
|
subHTimer = setTimeout(() => { subHTimer = null; persistSubHeights(); }, 250);
|
||||||
|
});
|
||||||
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
|
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
|
||||||
ch.subscribeAction('onVisibleRangeChange', (payload) => {
|
ch.subscribeAction('onVisibleRangeChange', (payload) => {
|
||||||
if (myEpoch !== epoch) return;
|
if (myEpoch !== epoch) return;
|
||||||
|
tradeTip.value = null; // 滚动后字母随 bar 移位,悬停明细立即失效
|
||||||
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
||||||
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
||||||
});
|
});
|
||||||
ch.setOffsetRightDistance(28);
|
ch.setOffsetRightDistance(28);
|
||||||
ch.scrollToRealTime();
|
ch.scrollToRealTime();
|
||||||
|
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
||||||
|
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
||||||
|
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
|
||||||
|
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
|
||||||
|
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
|
||||||
|
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
|
||||||
|
renderTradeMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function teardown() {
|
function teardown() {
|
||||||
|
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
||||||
if (container.value) dispose(container.value);
|
if (container.value) dispose(container.value);
|
||||||
chart = null;
|
chart = null;
|
||||||
hover.value = null;
|
hover.value = null;
|
||||||
|
tradeTip.value = null;
|
||||||
activeTool.value = '';
|
activeTool.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(build);
|
onMounted(build);
|
||||||
onBeforeUnmount(teardown);
|
onBeforeUnmount(teardown);
|
||||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
|
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(); });
|
watch(() => settings.priceTone, () => { teardown(); build(); });
|
||||||
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
|
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
|
||||||
@@ -544,11 +759,11 @@ watch(() => props.subHeights, () => {
|
|||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
|
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
|
||||||
const total = container.value?.clientHeight || 560;
|
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) {
|
for (const key of props.subPanes) {
|
||||||
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||||
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
|
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 });
|
}, { deep: true });
|
||||||
</script>
|
</script>
|
||||||
@@ -556,12 +771,13 @@ watch(() => props.subHeights, () => {
|
|||||||
<template>
|
<template>
|
||||||
<!-- mousemove 用 capture:klinecharts 在内部容器上以冒泡阶段监听并同步触发
|
<!-- mousemove 用 capture:klinecharts 在内部容器上以冒泡阶段监听并同步触发
|
||||||
onCrosshairChange→placeHover,capture 先于它更新 mx/my,避免用到上一次的坐标 -->
|
onCrosshairChange→placeHover,capture 先于它更新 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 ref="container" class="h-full w-full"></div>
|
||||||
|
|
||||||
<!-- 鼠标跟随信息框(贴鼠标,右/下缘自动翻转;每行一个指标,内容由浮层设置决定) -->
|
<!-- 鼠标跟随信息框(贴鼠标,右/下缘自动翻转;每行一个指标,内容由浮层设置决定)。
|
||||||
|
悬停交易字母时让位给明细浮层,两框几乎同点位叠加会呈现双层边框的重影 -->
|
||||||
<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"
|
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"
|
:style="hoverStyle"
|
||||||
>
|
>
|
||||||
@@ -578,8 +794,33 @@ watch(() => props.subHeights, () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 收不到后续 mousemove、onMouseLeave 不会触发,须在此清掉交易明细浮层 -->
|
||||||
|
<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">
|
<div class="flex items-center gap-0.5 px-1 py-0.5">
|
||||||
<button
|
<button
|
||||||
v-for="t in COMMON_TOOLS"
|
v-for="t in COMMON_TOOLS"
|
||||||
|
|||||||
110
frontend/src/components/SettingsModal.vue
Normal file
110
frontend/src/components/SettingsModal.vue
Normal 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>
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
|
import {
|
||||||
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField } from '@/api/types';
|
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 { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
|
||||||
import DetailKLine from './DetailKLine.vue';
|
import DetailKLine from './DetailKLine.vue';
|
||||||
|
|
||||||
@@ -9,7 +15,12 @@ const props = defineProps<{
|
|||||||
items: ScreenerItemOut[];
|
items: ScreenerItemOut[];
|
||||||
initial: string; // ts_code
|
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();
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
// ---------- 状态 ----------
|
// ---------- 状态 ----------
|
||||||
@@ -43,11 +54,6 @@ function setTimeframe(tf: Timeframe) {
|
|||||||
settings.setChartLayout({ timeframe: tf });
|
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 / 高度(全部随用户偏好持久化) ----------
|
// ---------- 副图 / MA / 高度(全部随用户偏好持久化) ----------
|
||||||
const SUBS = [
|
const SUBS = [
|
||||||
{ key: 'vol', label: 'VOL' },
|
{ key: 'vol', label: 'VOL' },
|
||||||
@@ -68,12 +74,7 @@ function toggleSub(key: string) {
|
|||||||
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
|
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function adjustHeight(key: string, delta: number) {
|
// 副图高度改为图内分隔条直接拖拽(DetailKLine 订阅 onPaneDrag 持久化),此处不再提供按钮
|
||||||
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 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 副图拖拽排序
|
// 副图拖拽排序
|
||||||
let dragKey: string | null = null;
|
let dragKey: string | null = null;
|
||||||
@@ -120,8 +121,57 @@ function toggleTipField(key: TooltipField) {
|
|||||||
tooltipFields: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
|
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 filteredItems = computed(() => {
|
||||||
const q = filter.value.trim().toLowerCase();
|
const q = filter.value.trim().toLowerCase();
|
||||||
if (!q) return props.items;
|
if (!q) return props.items;
|
||||||
@@ -176,17 +330,28 @@ const header = computed(() => {
|
|||||||
let fetchToken = 0;
|
let fetchToken = 0;
|
||||||
async function load(code: string) {
|
async function load(code: string) {
|
||||||
const token = ++fetchToken;
|
const token = ++fetchToken;
|
||||||
|
const anchor = jumpTs.value; // 跳转锚点:之后的所有重拉(复权/周期/MA)都围绕它取窗,视口位置不漂
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
data.value = null;
|
data.value = null;
|
||||||
try {
|
try {
|
||||||
const res = await getStockPreview(code, {
|
const res = await getStockPreview(code, {
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
// 锚定取数:窗口右缘=锚点+170 自然日(后端 end 不含当日),锚点恰好落在 240 根首吐窗口正中
|
||||||
|
end: anchor != null ? fmtDashDate(anchor + JUMP_END_DAYS * 86400000) : undefined,
|
||||||
adjust: adjust.value,
|
adjust: adjust.value,
|
||||||
timeframe: timeframe.value,
|
timeframe: timeframe.value,
|
||||||
mas: maPeriods.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) {
|
} catch (e) {
|
||||||
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
|
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -211,7 +376,13 @@ async function loadOlder(end: string, count: number) {
|
|||||||
return null; // 网络失败:图表停止向前翻页(不中断已渲染内容)
|
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(adjust, () => load(active.value));
|
||||||
watch(timeframe, () => load(active.value));
|
watch(timeframe, () => load(active.value));
|
||||||
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
|
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
|
||||||
@@ -236,10 +407,14 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
if (e.isComposing) return;
|
if (e.isComposing) return;
|
||||||
const t = e.target as HTMLElement | null;
|
const t = e.target as HTMLElement | null;
|
||||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
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 (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 emit('close');
|
||||||
}
|
}
|
||||||
|
else if (modalOpen) return;
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
|
||||||
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
||||||
}
|
}
|
||||||
@@ -254,7 +429,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
// ---------- 右侧信息栏增强:52周高低 / 年初至今(从日线序列算,无数据留空) ----------
|
// ---------- 右侧信息栏增强:52周高低 / 年初至今(从日线序列算,无数据留空) ----------
|
||||||
const stats = computed(() => {
|
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 };
|
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
|
||||||
const last = bars[bars.length - 1];
|
const last = bars[bars.length - 1];
|
||||||
const lastTs = new Date(last.ts);
|
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)"
|
@click="setAdjust(a.key)"
|
||||||
>{{ a.label }}</button>
|
>{{ a.label }}</button>
|
||||||
</div>
|
</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 ml-auto cursor-pointer" title="关闭 (Esc)" @click="emit('close')">
|
||||||
<button type="button" class="btn-ghost !px-2.5 !py-1" 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>
|
<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>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -399,7 +565,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
:class="subPanes.includes(s.key)
|
:class="subPanes.includes(s.key)
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-[#101014] text-[#9BA3AE] line-through'"
|
: 'bg-[#101014] text-[#9BA3AE] line-through'"
|
||||||
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
|
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 图内分隔线拖拽调高度' : '点击显示'"
|
||||||
@click="toggleSub(s.key)"
|
@click="toggleSub(s.key)"
|
||||||
@dragstart="onDragStart($event, s.key)"
|
@dragstart="onDragStart($event, s.key)"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@@ -407,10 +573,6 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
>
|
>
|
||||||
{{ s.label }}
|
{{ s.label }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -419,6 +581,20 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
title="主图叠加布林带"
|
title="主图叠加布林带"
|
||||||
@click="showBoll = !showBoll"
|
@click="showBoll = !showBoll"
|
||||||
>BOLL</button>
|
>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 配置 -->
|
<!-- MA 配置 -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
@@ -478,13 +654,40 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
{{ f.label }}
|
{{ f.label }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</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>
|
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-400">{{ error }}</div>
|
||||||
<DetailKLine
|
<DetailKLine
|
||||||
v-else-if="data && data.candles.length"
|
v-else-if="data && data.candles.length"
|
||||||
|
ref="klineRef"
|
||||||
:ticker="data.ts_code"
|
:ticker="data.ts_code"
|
||||||
:candles="data.candles"
|
:candles="data.candles"
|
||||||
:indicators="data.indicators"
|
:indicators="data.indicators"
|
||||||
@@ -507,6 +711,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
:show-boll="showBoll"
|
:show-boll="showBoll"
|
||||||
:timeframe="timeframe"
|
:timeframe="timeframe"
|
||||||
:tooltip-fields="tooltipFields"
|
: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 v-else class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">无数据</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,5 +784,64 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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 / .html,20MB 以内</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
207
frontend/src/stores/settings.ts
Normal file
207
frontend/src/stores/settings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
412
frontend/src/views/StocksView.vue
Normal file
412
frontend/src/views/StocksView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user