diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 5dcde88..14744f1 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -268,6 +268,47 @@ def _get_price_limit_info( return info +def _get_previous_closes( + repo, + symbol: str, + trade_dates: list[date], + asset_type: str, +) -> dict[date, float | None]: + """Return the previous trading day's adjusted close for each session.""" + if not trade_dates: + return {} + start = min(trade_dates) - timedelta(days=45) + end = max(trade_dates) + try: + daily = repo.get_daily_asset( + asset_type, + symbol, + start, + end, + columns=["date", "close"], + ).sort("date") + except Exception: + daily = None + if daily is None or daily.is_empty(): + return {trade_date: None for trade_date in trade_dates} + + closes: list[tuple[date, float]] = [] + for daily_date, close in daily.select(["date", "close"]).iter_rows(): + if close is None: + continue + numeric = float(close) + if math.isfinite(numeric) and numeric > 0: + closes.append((daily_date, numeric)) + + result: dict[date, float | None] = {} + for trade_date in trade_dates: + result[trade_date] = next( + (close for daily_date, close in reversed(closes) if daily_date < trade_date), + None, + ) + return result + + @router.get("/daily") def get_daily( request: Request, @@ -679,6 +720,73 @@ def get_minute_batch(request: Request, body: dict): return {"data": result} +@router.get("/minute-range") +def get_minute_range( + request: Request, + symbol: str = Query(..., description="标的代码"), + days: int = Query(10, ge=1, le=20, description="最近交易日数量"), +): + """读取单只标的最近 N 个已落库交易日的分钟 K。""" + import polars as pl + + repo = request.app.state.repo + asset_type = repo.resolve_asset_type(symbol) + stock_info = ( + _get_stock_info(repo, symbol) + if asset_type == "stock" + else _get_asset_info(repo, symbol, asset_type) + ) + base_response = { + "symbol": symbol, + "name": stock_info.get("name"), + "asset_type": asset_type, + "requested_days": days, + } + + # 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。 + if asset_type == "index": + return {**base_response, "sessions": [], "source": "none"} + + end = cn_today() + start = end - timedelta(days=days * 3 + 20) + minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type) + if minute.is_empty() or "datetime" not in minute.columns: + return {**base_response, "sessions": [], "source": "none"} + + minute = minute.with_columns( + pl.col("datetime").dt.date().alias("_trade_date"), + ) + trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:] + previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type) + row_columns = [ + column + for column in ( + "datetime", "open", "high", "low", "close", "volume", "amount" + ) + if column in minute.columns + ] + sessions = [] + for trade_date in trade_dates: + rows = ( + minute.filter(pl.col("_trade_date") == trade_date) + .sort("datetime") + .select(row_columns) + .to_dicts() + ) + if rows: + sessions.append({ + "date": trade_date.isoformat(), + "prev_close": previous_closes.get(trade_date), + "rows": rows, + }) + + return { + **base_response, + "sessions": sessions, + "source": "local" if sessions else "none", + } + + @router.get("/minute") def get_minute( request: Request, @@ -721,13 +829,20 @@ def get_minute( price_limit = _get_price_limit_info( repo, symbol, trade_date, asset_type, stock_name, ) + prev_close = _get_previous_closes( + repo, symbol, [trade_date], asset_type, + ).get(trade_date) return { "symbol": symbol, "name": stock_name, "stock_info": stock_info, "date": str(trade_date), "rows": df.to_dicts(), "source": "live", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } + prev_close = _get_previous_closes( + repo, symbol, [trade_date], asset_type, + ).get(trade_date) price_limit = _get_price_limit_info( repo, symbol, trade_date, asset_type, stock_name, ) @@ -758,6 +873,7 @@ def get_minute( "date": str(trade_date), "rows": df.to_dicts(), "source": "local", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } # 本地不完整或无数据 → 从 TickFlow 实时拉取 @@ -768,6 +884,7 @@ def get_minute( "source": "live" if not live_df.is_empty() else "none", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } @@ -909,13 +1026,21 @@ async def sync_minute_single(request: Request, body: dict): body: { "symbol": "000001.SZ" } 用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。 """ + import asyncio + from app.services.preferences import get_minute_sync_days - from app.tickflow.capabilities import Cap symbol = body.get("symbol", "").strip() if not symbol: raise HTTPException(status_code=400, detail="symbol 不能为空") + requested_days = body.get("days") + if requested_days is not None: + if isinstance(requested_days, bool) or not isinstance(requested_days, int): + raise HTTPException(status_code=400, detail="days 必须是整数") + if requested_days < 1 or requested_days > 30: + raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间") + repo = request.app.state.repo capset = request.app.state.capabilities @@ -927,7 +1052,7 @@ async def sync_minute_single(request: Request, body: dict): if not _minute_allowed(capset): raise HTTPException(status_code=403, detail="需要 Pro+ 权限") - days = get_minute_sync_days() + days = requested_days if requested_days is not None else get_minute_sync_days() loop = asyncio.get_event_loop() def _run(): diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 8780b16..867bf53 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -36,11 +36,22 @@ _OCR_LIMITER = anyio.CapacityLimiter(2) class AddRequest(BaseModel): symbol: str note: str = "" + group_id: str | None = None class BatchAddRequest(BaseModel): symbols: list[str] note: str = "" + group_id: str | None = None + + +class GroupNameRequest(BaseModel): + name: str + color: str | None = None + + +class GroupAssignRequest(BaseModel): + group_id: str | None = None def _with_names(rows: list[dict], request: Request) -> list[dict]: @@ -64,20 +75,54 @@ def list_all(request: Request): @router.post("") def add_one(req: AddRequest, request: Request): - rows = watchlist.add(req.symbol, req.note) + try: + rows = watchlist.add(req.symbol, req.note, req.group_id) + except ValueError as e: + raise HTTPException(400, str(e)) from e return {"symbols": _with_names(rows, request)} @router.post("/batch") def add_batch(req: BatchAddRequest, request: Request): - existing = {r["symbol"] for r in watchlist.list_symbols()} - added = 0 - for sym in req.symbols: - if sym not in existing: - added += 1 - existing.add(sym) - watchlist.add(sym, req.note) - return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added} + try: + rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request), "added": added} + + +@router.get("/groups") +def list_groups(): + return {"groups": watchlist.list_groups()} + + +@router.post("/groups") +def create_group(req: GroupNameRequest): + try: + groups, group = watchlist.create_group(req.name, req.color) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"groups": groups, "group": group} + + +@router.put("/groups/{group_id}") +def rename_group(group_id: str, req: GroupNameRequest): + try: + groups = watchlist.rename_group(group_id, req.name, req.color) + except KeyError as e: + raise HTTPException(404, "自选分组不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"groups": groups} + + +@router.delete("/groups/{group_id}") +def delete_group(group_id: str, request: Request): + try: + groups, rows = watchlist.delete_group(group_id) + except KeyError as e: + raise HTTPException(404, "自选分组不存在") from e + return {"groups": groups, "symbols": _with_names(rows, request)} @router.get("/ocr-status") @@ -131,6 +176,17 @@ def move_one_to_top(symbol: str, request: Request): return {"symbols": _with_names(rows, request)} +@router.put("/{symbol}/group") +def assign_group(symbol: str, req: GroupAssignRequest, request: Request): + try: + rows = watchlist.set_group(symbol, req.group_id) + except KeyError as e: + raise HTTPException(404, "自选标的不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request)} + + @router.delete("/{symbol}") def remove_one(symbol: str, request: Request): rows = watchlist.remove(symbol) diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 476a56c..e00bcdb 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -1,10 +1,17 @@ -"""自选股服务(§6.1)。 +"""自选股与分组服务。 -存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。 +自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的 +``watchlist_groups.json``。历史 Parquet 缺少 ``group_id`` 时按未分组读取。 """ from __future__ import annotations +import json import logging +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeout from datetime import datetime from pathlib import Path @@ -17,6 +24,30 @@ from app.tickflow.rate_limits import chunked, resolve_limit logger = logging.getLogger(__name__) +_LOCK = threading.RLock() +_MAX_GROUP_NAME_LENGTH = 24 +DEFAULT_GROUP_COLOR = "sky" +GROUP_COLORS = frozenset({ + "sky", + "blue", + "indigo", + "violet", + "fuchsia", + "rose", + "orange", + "amber", + "lime", + "emerald", + "teal", + "cyan", +}) +_ENTRY_SCHEMA = { + "symbol": pl.Utf8, + "added_at": pl.Utf8, + "note": pl.Utf8, + "group_id": pl.Utf8, +} + def _path() -> Path: p = settings.data_dir / "user_data" / "watchlist.parquet" @@ -24,70 +55,230 @@ def _path() -> Path: return p -def list_symbols() -> list[dict]: +def _groups_path() -> Path: + p = settings.data_dir / "user_data" / "watchlist_groups.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _empty_entries() -> pl.DataFrame: + return pl.DataFrame(schema=_ENTRY_SCHEMA) + + +def _read_entries() -> pl.DataFrame: p = _path() if not p.exists(): - return [] + return _empty_entries() df = pl.read_parquet(p) - if df.is_empty(): - return [] - return df.to_dicts() + defaults = {"symbol": "", "added_at": "", "note": "", "group_id": None} + for column, dtype in _ENTRY_SCHEMA.items(): + if column not in df.columns: + df = df.with_columns(pl.lit(defaults[column], dtype=dtype).alias(column)) + return df.select(list(_ENTRY_SCHEMA)) -def add(symbol: str, note: str = "") -> list[dict]: +def _write_entries(df: pl.DataFrame) -> None: p = _path() - if p.exists(): - df = pl.read_parquet(p) - # 已存在则先移除,后面重新插入到最前面 - if symbol in df["symbol"].to_list(): - df = df.filter(pl.col("symbol") != symbol) - else: - df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}) + tmp = p.with_suffix(p.suffix + ".tmp") + df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp) + os.replace(tmp, p) - new_row = pl.DataFrame({ - "symbol": [symbol], - "added_at": [datetime.utcnow().isoformat(timespec="seconds")], - "note": [note], - }) - out = pl.concat([new_row, df], how="diagonal_relaxed") - out.write_parquet(p) - return out.to_dicts() + +def _read_groups() -> list[dict]: + p = _groups_path() + if not p.exists(): + return [] + try: + raw = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("自选分组配置损坏,请检查 watchlist_groups.json") from exc + if not isinstance(raw, list): + raise ValueError("自选分组配置格式不正确") + groups = [] + for item in raw: + if not isinstance(item, dict) or not item.get("id") or not item.get("name"): + continue + color = str(item.get("color", DEFAULT_GROUP_COLOR)) + groups.append({ + "id": str(item["id"]), + "name": str(item["name"]), + "color": color if color in GROUP_COLORS else DEFAULT_GROUP_COLOR, + }) + return groups + + +def _write_groups(groups: list[dict]) -> None: + p = _groups_path() + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + + +def _normalize_group_name(name: str) -> str: + normalized = name.strip() + if not normalized: + raise ValueError("分组名称不能为空") + if len(normalized) > _MAX_GROUP_NAME_LENGTH: + raise ValueError(f"分组名称不能超过 {_MAX_GROUP_NAME_LENGTH} 个字符") + return normalized + + +def _normalize_group_color(color: str | None) -> str: + normalized = (color or DEFAULT_GROUP_COLOR).strip().lower() + if normalized not in GROUP_COLORS: + raise ValueError("不支持的分组颜色") + return normalized + + +def _validate_group_id(group_id: str | None, groups: list[dict]) -> None: + if group_id is not None and not any(group["id"] == group_id for group in groups): + raise ValueError("自选分组不存在") + + +def list_symbols() -> list[dict]: + with _LOCK: + df = _read_entries() + return [] if df.is_empty() else df.to_dicts() + + +def add(symbol: str, note: str = "", group_id: str | None = None) -> list[dict]: + rows, _ = add_batch([symbol], note=note, group_id=group_id) + return rows + + +def add_batch( + symbols: list[str], + note: str = "", + group_id: str | None = None, +) -> tuple[list[dict], int]: + """批量添加并保持既有语义:每个新处理的标的移动到列表最前面。""" + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + rows = _read_entries().to_dicts() + added = 0 + for symbol in symbols: + existing = next((row for row in rows if row["symbol"] == symbol), None) + if existing is None: + added += 1 + rows = [row for row in rows if row["symbol"] != symbol] + resolved_group_id = ( + group_id if group_id is not None else (existing or {}).get("group_id") + ) + rows.insert(0, { + "symbol": symbol, + "added_at": datetime.utcnow().isoformat(timespec="seconds"), + "note": note, + "group_id": resolved_group_id, + }) + out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries() + _write_entries(out) + return out.to_dicts(), added def remove(symbol: str) -> list[dict]: - p = _path() - if not p.exists(): - return [] - df = pl.read_parquet(p) - df = df.filter(pl.col("symbol") != symbol) - df.write_parquet(p) - return df.to_dicts() + with _LOCK: + df = _read_entries().filter(pl.col("symbol") != symbol) + _write_entries(df) + return df.to_dicts() def move_to_top(symbol: str) -> list[dict]: - p = _path() - if not p.exists(): - return [] - df = pl.read_parquet(p) - if df.is_empty() or symbol not in df["symbol"].to_list(): - return df.to_dicts() - target = df.filter(pl.col("symbol") == symbol) - rest = df.filter(pl.col("symbol") != symbol) - out = pl.concat([target, rest], how="diagonal_relaxed") - out.write_parquet(p) - return out.to_dicts() + with _LOCK: + df = _read_entries() + if df.is_empty() or symbol not in df["symbol"].to_list(): + return df.to_dicts() + target = df.filter(pl.col("symbol") == symbol) + rest = df.filter(pl.col("symbol") != symbol) + out = pl.concat([target, rest], how="diagonal_relaxed") + _write_entries(out) + return out.to_dicts() def clear() -> int: """清空自选列表。返回移除的数量。""" - p = _path() - if not p.exists(): - return 0 - df = pl.read_parquet(p) - count = df.height - if count > 0: - pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p) - return count + with _LOCK: + df = _read_entries() + count = df.height + if count > 0: + _write_entries(_empty_entries()) + return count + + +def list_groups() -> list[dict]: + with _LOCK: + return _read_groups() + + +def create_group(name: str, color: str | None = None) -> tuple[list[dict], dict]: + with _LOCK: + normalized = _normalize_group_name(name) + normalized_color = _normalize_group_color(color) + groups = _read_groups() + if any(group["name"].casefold() == normalized.casefold() for group in groups): + raise ValueError("分组名称已存在") + group = { + "id": uuid.uuid4().hex, + "name": normalized, + "color": normalized_color, + } + groups.append(group) + _write_groups(groups) + return groups, group + + +def rename_group(group_id: str, name: str, color: str | None = None) -> list[dict]: + with _LOCK: + normalized = _normalize_group_name(name) + groups = _read_groups() + target = next((group for group in groups if group["id"] == group_id), None) + if target is None: + raise KeyError(group_id) + if any( + group["id"] != group_id and group["name"].casefold() == normalized.casefold() + for group in groups + ): + raise ValueError("分组名称已存在") + target["name"] = normalized + if color is not None: + target["color"] = _normalize_group_color(color) + _write_groups(groups) + return groups + + +def delete_group(group_id: str) -> tuple[list[dict], list[dict]]: + """删除分组定义,原分组内的自选保留并转为未分组。""" + with _LOCK: + groups = _read_groups() + if not any(group["id"] == group_id for group in groups): + raise KeyError(group_id) + df = _read_entries().with_columns( + pl.when(pl.col("group_id") == group_id) + .then(None) + .otherwise(pl.col("group_id")) + .alias("group_id") + ) + remaining = [group for group in groups if group["id"] != group_id] + _write_entries(df) + _write_groups(remaining) + return remaining, df.to_dicts() + + +def set_group(symbol: str, group_id: str | None) -> list[dict]: + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + df = _read_entries() + if symbol not in df["symbol"].to_list(): + raise KeyError(symbol) + df = df.with_columns( + pl.when(pl.col("symbol") == symbol) + .then(pl.lit(group_id, dtype=pl.Utf8)) + .otherwise(pl.col("group_id")) + .alias("group_id") + ) + _write_entries(df) + return df.to_dicts() def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]: @@ -96,8 +287,6 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8 优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。 timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。 """ - from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout - if not symbols: return [] diff --git a/backend/tests/test_minute_range_api.py b/backend/tests/test_minute_range_api.py new file mode 100644 index 0000000..9f46a4b --- /dev/null +++ b/backend/tests/test_minute_range_api.py @@ -0,0 +1,107 @@ +"""多日分时 API 契约。""" + +import asyncio +from datetime import date, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import kline as kline_api + + +def _request(repo=None, capset=None): + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + repo=repo or MagicMock(), + capabilities=capset or MagicMock(), + ) + ) + ) + + +def test_minute_range_returns_latest_sessions_with_previous_closes(): + repo = MagicMock() + repo.resolve_asset_type.return_value = "stock" + repo.execute_one.return_value = ("浦发银行", 1.0, 1.0) + repo.get_minute_range.return_value = pl.DataFrame({ + "symbol": ["600000.SH"] * 3, + "datetime": [ + datetime(2026, 8, 5, 1, 30), + datetime(2026, 8, 6, 1, 30), + datetime(2026, 8, 7, 1, 30), + ], + "open": [10.0, 11.0, 12.0], + "high": [10.2, 11.2, 12.2], + "low": [9.8, 10.8, 11.8], + "close": [10.1, 11.1, 12.1], + "volume": [100.0, 110.0, 120.0], + "amount": [101_000.0, 122_100.0, 145_200.0], + }) + repo.get_daily_asset.return_value = pl.DataFrame({ + "date": [ + date(2026, 8, 4), + date(2026, 8, 5), + date(2026, 8, 6), + date(2026, 8, 7), + ], + "close": [9.9, 10.1, 11.1, 12.1], + }) + + result = kline_api.get_minute_range(_request(repo), "600000.SH", 2) + + assert result["name"] == "浦发银行" + assert result["requested_days"] == 2 + assert result["source"] == "local" + assert [session["date"] for session in result["sessions"]] == [ + "2026-08-06", + "2026-08-07", + ] + assert [session["prev_close"] for session in result["sessions"]] == [ + 10.1, + 11.1, + ] + assert result["sessions"][0]["rows"][0]["close"] == 11.1 + + +def test_minute_range_does_not_read_stock_store_for_index(): + repo = MagicMock() + repo.resolve_asset_type.return_value = "index" + repo.get_instruments_asset.return_value = pl.DataFrame() + + result = kline_api.get_minute_range(_request(repo), "000001.SH", 10) + + assert result["asset_type"] == "index" + assert result["sessions"] == [] + repo.get_minute_range.assert_not_called() + + +def test_sync_minute_single_uses_requested_days(monkeypatch): + repo = MagicMock() + repo.resolve_asset_type.return_value = "stock" + capset = MagicMock() + sync = MagicMock(return_value=2400) + refresh = MagicMock() + monkeypatch.setattr(kline_api, "_minute_allowed", lambda _: True) + monkeypatch.setattr(kline_api.kline_sync, "sync_and_persist_minute", sync) + monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh) + + result = asyncio.run(kline_api.sync_minute_single( + _request(repo, capset), + {"symbol": "600000.SH", "days": 10}, + )) + + assert result["rows"] == 2400 + sync.assert_called_once_with(["600000.SH"], repo, capset, days=10) + refresh.assert_called_once_with(repo, "kline_minute") + + +def test_sync_minute_single_rejects_invalid_days(): + with pytest.raises(HTTPException, match="days 必须在 1 到 30 之间"): + asyncio.run(kline_api.sync_minute_single( + _request(), + {"symbol": "600000.SH", "days": 0}, + )) diff --git a/backend/tests/test_watchlist_groups.py b/backend/tests/test_watchlist_groups.py new file mode 100644 index 0000000..83b550e --- /dev/null +++ b/backend/tests/test_watchlist_groups.py @@ -0,0 +1,114 @@ +"""自选分组持久化与 API 契约。""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import watchlist as watchlist_api +from app.config import settings +from app.services import watchlist + + +def _request(): + repo = MagicMock() + repo.get_name_map.return_value = {} + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo))) + + +def test_historical_watchlist_is_read_as_ungrouped(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "watchlist.parquet" + path.parent.mkdir(parents=True) + pl.DataFrame({ + "symbol": ["600000.SH"], + "added_at": ["2026-08-08T10:00:00"], + "note": [""], + }).write_parquet(path) + + assert watchlist.list_symbols()[0]["group_id"] is None + + +def test_group_lifecycle_preserves_watchlist_entries(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + groups, created = watchlist.create_group(" 短线 ", "orange") + assert groups == [{"id": created["id"], "name": "短线", "color": "orange"}] + + watchlist.add("600000.SH", group_id=created["id"]) + watchlist.add("000001.SZ") + assert watchlist.list_symbols()[1]["group_id"] == created["id"] + + renamed = watchlist.rename_group(created["id"], "观察", "fuchsia") + assert renamed[0]["name"] == "观察" + assert renamed[0]["color"] == "fuchsia" + + remaining, rows = watchlist.delete_group(created["id"]) + assert remaining == [] + assert {row["symbol"] for row in rows} == {"600000.SH", "000001.SZ"} + assert all(row["group_id"] is None for row in rows) + + +def test_group_validation_and_assignment_errors(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, created = watchlist.create_group("核心") + watchlist.add("600000.SH") + + with pytest.raises(ValueError, match="已存在"): + watchlist.create_group("核心") + with pytest.raises(ValueError, match="颜色"): + watchlist.create_group("无效颜色", "black") + with pytest.raises(ValueError, match="颜色"): + watchlist.rename_group(created["id"], "核心", "black") + with pytest.raises(ValueError, match="不存在"): + watchlist.set_group("600000.SH", "missing") + with pytest.raises(KeyError): + watchlist.set_group("000001.SZ", created["id"]) + + rows = watchlist.set_group("600000.SH", created["id"]) + assert rows[0]["group_id"] == created["id"] + rows = watchlist.set_group("600000.SH", None) + assert rows[0]["group_id"] is None + + +def test_group_api_contract(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + request = _request() + created = watchlist_api.create_group( + watchlist_api.GroupNameRequest(name="中线", color="teal") + ) + group_id = created["group"]["id"] + assert created["group"]["color"] == "teal" + + added = watchlist_api.add_one( + watchlist_api.AddRequest(symbol="600000.SH", group_id=group_id), + request, + ) + assert added["symbols"][0]["group_id"] == group_id + + moved = watchlist_api.assign_group( + "600000.SH", + watchlist_api.GroupAssignRequest(group_id=None), + request, + ) + assert moved["symbols"][0]["group_id"] is None + + with pytest.raises(HTTPException) as exc_info: + watchlist_api.rename_group("missing", watchlist_api.GroupNameRequest(name="无效")) + assert exc_info.value.status_code == 404 + + +def test_historical_groups_default_to_sky(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "watchlist_groups.json" + path.parent.mkdir(parents=True) + path.write_text( + '[{"id":"legacy","name":"旧分组"},' + '{"id":"invalid","name":"未知颜色","color":"black"}]', + encoding="utf-8", + ) + + assert watchlist.list_groups() == [ + {"id": "legacy", "name": "旧分组", "color": "sky"}, + {"id": "invalid", "name": "未知颜色", "color": "sky"}, + ] diff --git a/backend/uv.lock b/backend/uv.lock index f38c899..6b6151f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1958,6 +1958,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, +] + [[package]] name = "pytesseract" version = "0.3.13" @@ -2504,7 +2513,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.83" +version = "0.1.88" source = { editable = "." } dependencies = [ { name = "apscheduler" }, @@ -2523,6 +2532,7 @@ dependencies = [ { name = "pyarrow" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pypinyin" }, { name = "pytesseract" }, { name = "python-dotenv" }, { name = "python-multipart" }, @@ -2570,6 +2580,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, + { name = "pypinyin", specifier = ">=0.50" }, { name = "pytesseract", specifier = ">=0.3.10" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, diff --git a/frontend/src/components/EChartsIntraday.tsx b/frontend/src/components/EChartsIntraday.tsx index b4ba6a8..c308f01 100644 --- a/frontend/src/components/EChartsIntraday.tsx +++ b/frontend/src/components/EChartsIntraday.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' import type { MinuteKlineRow, PriceLimitInfo } from '@/lib/api' +import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart' import { useChartTheme, type ChartTheme } from '@/lib/theme' type YMode = 'adaptive' | 'limit' @@ -26,26 +27,6 @@ interface Props { showAvgLine?: boolean } -function fmtTime(dt: string): string { - const match = dt.match(/(\d{2}):(\d{2})/) - if (!match) return dt.slice(11, 16) - const h = (parseInt(match[1]) + 8) % 24 - return `${String(h).padStart(2, '0')}:${match[2]}` -} - -function computeAvgPrice(data: MinuteKlineRow[]): number[] { - // 分时均线 = 累计成交额 / 累计成交量(手→股) - const result: number[] = [] - let sumAmt = 0 - let sumVol = 0 - for (const d of data) { - sumAmt += d.amount - sumVol += d.volume * 100 - result.push(sumVol > 0 ? sumAmt / sumVol : d.close) - } - return result -} - function fmtAmt(v: number): string { if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿` if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万` @@ -56,29 +37,6 @@ function isValidPrice(v: number | null | undefined): v is number { return typeof v === 'number' && Number.isFinite(v) && v > 0 } -/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */ -function generateFullDayTimes(): string[] { - const times: string[] = [] - // 上午 9:30 ~ 11:30 (121 分钟) - for (let h = 9; h <= 11; h++) { - const startM = h === 9 ? 30 : 0 - const endM = h === 11 ? 30 : 59 - for (let m = startM; m <= endM; m++) { - times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) - } - } - // 下午 13:00 ~ 15:00 (121 分钟) - for (let h = 13; h <= 15; h++) { - const endM = h === 15 ? 0 : 59 - for (let m = 0; m <= endM; m++) { - times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) - } - } - return times -} - -const FULL_DAY_TIMES = generateFullDayTimes() - /** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */ function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): { limitUp: number // 涨停价 (四舍五入) @@ -112,7 +70,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP const volNeutral = 'rgba(161,161,170,0.5)' for (let i = 0; i < data.length; i++) { - const timeKey = fmtTime(data[i].datetime) + const timeKey = formatMinuteTime(data[i].datetime) const idx = timeIndexMap.get(timeKey) if (idx !== undefined) { closes[idx] = data[i].close @@ -414,7 +372,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi const [infoIdx, setInfoIdx] = useState(data.length - 1) const [yMode, setYMode] = useState('adaptive') const ct = useChartTheme() - const avgPrices = useMemo(() => computeAvgPrice(data), [data]) + const avgPrices = useMemo(() => computeIntradayAverage(data), [data]) // 分时线颜色:基于最新价 vs 昨收 const lastClose = data.length > 0 ? data[data.length - 1].close : null @@ -480,7 +438,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) const mapping = new Map() for (let i = 0; i < data.length; i++) { - const timeKey = fmtTime(data[i].datetime) + const timeKey = formatMinuteTime(data[i].datetime) const fullDayIdx = timeIndexMap.get(timeKey) if (fullDayIdx !== undefined) { mapping.set(fullDayIdx, i) diff --git a/frontend/src/components/EChartsMultiDayIntraday.tsx b/frontend/src/components/EChartsMultiDayIntraday.tsx new file mode 100644 index 0000000..32f8a22 --- /dev/null +++ b/frontend/src/components/EChartsMultiDayIntraday.tsx @@ -0,0 +1,373 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as echarts from 'echarts' +import type { ECharts, EChartsOption } from 'echarts' +import type { MinuteKlineRow, MinuteKlineSession } from '@/lib/api' +import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart' +import { useChartTheme } from '@/lib/theme' + +const COLORS = { + up: '#C74040', + down: '#2D9B65', + flat: '#A1A1AA', + average: '#F59E0B', + volumeUp: 'rgba(240,68,56,0.58)', + volumeDown: 'rgba(18,183,106,0.58)', + volumeFlat: 'rgba(161,161,170,0.45)', +} + +interface Props { + sessions: MinuteKlineSession[] + height?: number +} + +interface InfoPoint { + date: string + row: MinuteKlineRow + average: number + prevClose: number | null +} + +function formatAmount(value: number): string { + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}亿` + if (value >= 10_000) return `${(value / 10_000).toFixed(0)}万` + return value.toFixed(0) +} + +function priceColor(close: number, prevClose: number | null): string { + if (prevClose == null || close === prevClose) return COLORS.flat + return close > prevClose ? COLORS.up : COLORS.down +} + +function buildModel(sessions: MinuteKlineSession[]) { + const categories: string[] = [] + const volumeData: ({ value: number; itemStyle: { color: string } } | null)[] = [] + const dayLabelByIndex = new Map() + const dayStartIndexes: number[] = [] + const pointByIndex = new Map() + const dayRanges: { + start: number + session: MinuteKlineSession + values: (number | null)[] + averages: (number | null)[] + }[] = [] + const priceValues: number[] = [] + + const labelStep = Math.max(1, Math.ceil(sessions.length / 10)) + for (let sessionIndex = 0; sessionIndex < sessions.length; sessionIndex++) { + const session = sessions[sessionIndex] + const start = categories.length + dayStartIndexes.push(start) + if (sessionIndex % labelStep === 0 || sessionIndex === sessions.length - 1) { + dayLabelByIndex.set(start + Math.floor(FULL_DAY_TIMES.length / 2), session.date.slice(5)) + } + + const averagePrices = computeIntradayAverage(session.rows) + const rowsByTime = new Map() + session.rows.forEach((row, index) => { + rowsByTime.set(formatMinuteTime(row.datetime), { + row, + average: averagePrices[index], + }) + }) + + const dayValues: (number | null)[] = [] + const dayAverages: (number | null)[] = [] + for (const time of FULL_DAY_TIMES) { + const point = rowsByTime.get(time) + const index = categories.length + categories.push(`${session.date} ${time}`) + if (!point) { + dayValues.push(null) + dayAverages.push(null) + volumeData.push(null) + continue + } + + const { row, average } = point + dayValues.push(row.close) + dayAverages.push(average) + volumeData.push({ + value: row.volume, + itemStyle: { + color: row.close > row.open + ? COLORS.volumeUp + : row.close < row.open + ? COLORS.volumeDown + : COLORS.volumeFlat, + }, + }) + priceValues.push(row.low, row.high, average) + pointByIndex.set(index, { + date: session.date, + row, + average, + prevClose: session.prev_close, + }) + } + + dayRanges.push({ + start, + session, + values: dayValues, + averages: dayAverages, + }) + + if (sessionIndex < sessions.length - 1) { + categories.push(`${session.date} gap`) + volumeData.push(null) + } + } + + return { + categories, + volumeData, + dayLabelByIndex, + dayStartIndexes, + pointByIndex, + dayRanges, + priceValues, + latest: pointByIndex.size > 0 + ? Array.from(pointByIndex.values())[pointByIndex.size - 1] + : null, + } +} + +export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const resizeObserverRef = useRef(null) + const model = useMemo(() => buildModel(sessions), [sessions]) + const modelRef = useRef(model) + modelRef.current = model + const [info, setInfo] = useState(model.latest) + const theme = useChartTheme() + + useEffect(() => { + setInfo(model.latest) + }, [model]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + let chart = chartRef.current + if (!chart) { + chart = echarts.init(container, undefined, { renderer: 'canvas' }) + chartRef.current = chart + resizeObserverRef.current = new ResizeObserver(() => chart?.resize()) + resizeObserverRef.current.observe(container) + + chart.on('updateAxisPointer', (event: any) => { + const axisInfo = event.axesInfo?.find((item: any) => item.axisDim === 'x' && item.axisIndex === 0) + ?? event.axesInfo?.[0] + const rawValue = axisInfo?.value + const current = modelRef.current + const index = typeof rawValue === 'number' + ? rawValue + : current.categories.indexOf(String(rawValue)) + const point = current.pointByIndex.get(index) + if (point) setInfo(point) + }) + chart.on('globalout', () => setInfo(modelRef.current.latest)) + } + + const minPrice = model.priceValues.length > 0 ? Math.min(...model.priceValues) : 0 + const maxPrice = model.priceValues.length > 0 ? Math.max(...model.priceValues) : 1 + const padding = Math.max((maxPrice - minPrice) * 0.08, maxPrice * 0.002) + const totalLength = model.categories.length + const priceSeries: any[] = model.dayRanges.map(({ start, session, values }) => { + const data = new Array(totalLength).fill(null) as (number | null)[] + for (let index = 0; index < values.length; index++) data[start + index] = values[index] + const last = session.rows[session.rows.length - 1] + const color = last ? priceColor(last.close, session.prev_close) : COLORS.flat + return { + name: session.date, + type: 'line', + data, + symbol: 'none', + smooth: false, + connectNulls: true, + lineStyle: { width: 1.2, color }, + areaStyle: { color, opacity: 0.08 }, + emphasis: { disabled: true }, + } + }) + + const boundaryData = model.dayStartIndexes.slice(1).map(index => ({ + xAxis: model.categories[index], + lineStyle: { color: theme.grid, width: 1 }, + label: { show: false }, + })) + if (priceSeries.length > 0 && boundaryData.length > 0) { + priceSeries[0].markLine = { + symbol: 'none', + silent: true, + data: boundaryData, + } + } + const averageSeries: any[] = model.dayRanges.map(({ start, session, averages }) => { + const data = new Array(totalLength).fill(null) as (number | null)[] + for (let index = 0; index < averages.length; index++) data[start + index] = averages[index] + return { + name: `${session.date} 均价`, + type: 'line', + data, + symbol: 'none', + connectNulls: true, + lineStyle: { width: 1, color: COLORS.average }, + emphasis: { disabled: true }, + } + }) + const option: EChartsOption = { + animation: false, + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + backgroundColor: 'transparent', + borderWidth: 0, + formatter: () => '', + axisPointer: { + type: 'cross', + label: { + show: true, + backgroundColor: theme.tooltipBg, + borderColor: theme.tooltipBorder, + borderWidth: 1, + color: theme.tooltipText, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + }, + crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + }, + }, + axisPointer: { link: [{ xAxisIndex: 'all' }] }, + grid: [ + { left: 58, right: 18, top: 16, bottom: '28%' }, + { left: 58, right: 18, top: '76%', bottom: 22 }, + ], + xAxis: [ + { + type: 'category', + data: model.categories, + boundaryGap: false, + axisLine: { lineStyle: { color: theme.grid } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + color: theme.text, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + interval: 0, + hideOverlap: true, + formatter: (_value: string, index: number) => model.dayLabelByIndex.get(index) ?? '', + }, + axisPointer: { + label: { + formatter: (params: any) => { + const value = String(params.value ?? '') + return value.endsWith(' gap') ? '' : value.slice(5) + }, + }, + }, + }, + { + type: 'category', + gridIndex: 1, + data: model.categories, + boundaryGap: false, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + type: 'value', + min: minPrice - padding, + max: maxPrice + padding, + scale: true, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: theme.grid } }, + axisLabel: { + color: theme.text, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + formatter: (value: number) => value.toFixed(2), + }, + }, + { + type: 'value', + gridIndex: 1, + scale: true, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ], + dataZoom: [{ + type: 'inside', + xAxisIndex: [0, 1], + start: 0, + end: 100, + minValueSpan: FULL_DAY_TIMES.length, + filterMode: 'none', + }], + series: [ + ...priceSeries, + ...averageSeries, + { + name: '成交量', + type: 'bar', + data: model.volumeData, + xAxisIndex: 1, + yAxisIndex: 1, + }, + ], + } + chart.setOption(option, true) + }, [height, model, theme]) + + useEffect(() => () => { + chartRef.current?.off('updateAxisPointer') + chartRef.current?.off('globalout') + resizeObserverRef.current?.disconnect() + chartRef.current?.dispose() + chartRef.current = null + }, []) + + const changePct = info?.prevClose + ? (info.row.close - info.prevClose) / info.prevClose * 100 + : null + const infoColor = info ? priceColor(info.row.close, info.prevClose) : COLORS.flat + const rowCount = sessions.reduce((total, session) => total + session.rows.length, 0) + + return ( +
+
+
+ {info ? ( + <> + {info.date} {formatMinuteTime(info.row.datetime)} + {info.row.open.toFixed(2)} + {info.row.high.toFixed(2)} + {info.row.low.toFixed(2)} + {info.row.close.toFixed(2)} + {changePct != null && ( + {changePct >= 0 ? '+' : ''}{changePct.toFixed(2)}% + )} + 均价{info.average.toFixed(2)} + {info.row.volume.toFixed(0)} + {formatAmount(info.row.amount)} + + ) : } +
+
{sessions.length} 个交易日 · {rowCount} 分钟
+
+
+
+ ) +} diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 1063249..7b5891f 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -3,6 +3,7 @@ import { Settings2, RadioTower, Star } from 'lucide-react' import type { KlineRow, FinancialMetricRecord } from '@/lib/api' import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' +import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' const BULL = '#C74040' @@ -20,9 +21,11 @@ interface Props { financialMetrics?: FinancialMetricRecord /** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */ onMonitor?: () => void - /** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */ + /** 自选状态与操作(传入对应回调时渲染 Star 图标) */ inWatchlist?: boolean - onToggleWatchlist?: () => void + onAddToWatchlist?: (groupId: string | null) => void + onRemoveFromWatchlist?: () => void + watchlistPending?: boolean } /** @@ -91,7 +94,20 @@ function renderExtInline( ) } -export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) { +export function StockInfoBar({ + symbol, + name, + stockInfo, + rows, + fields, + onFieldsChange, + financialMetrics, + onMonitor, + inWatchlist, + onAddToWatchlist, + onRemoveFromWatchlist, + watchlistPending, +}: Props) { // 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前 const [customizerOpen, setCustomizerOpen] = useState(false) // ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰 @@ -216,15 +232,27 @@ export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsCh {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
- {onToggleWatchlist && ( + {inWatchlist && onRemoveFromWatchlist ? ( - )} + ) : !inWatchlist && onAddToWatchlist ? ( + + + + ) : null} {onMonitor && ( +
+ ) + } + + if (sessions.length === 0) { + return ( +
+ {syncMinute.isPending ? ( + <> + + 正在获取近 {days} 日分钟 K… + + ) : ( + <> + {isIndex ? '指数暂无分钟数据' : '本地暂无可展示的分钟数据'} + {!isIndex && ( + + )} + + )} + {syncMinute.isError && {errorMessage(syncMinute.error)}} +
+ ) + } + + return ( +
+ {showCoverage && ( +
+ 当前有 {sessions.length} 个交易日数据,目标 {days} 日 + +
+ )} + + {syncMinute.isError && ( +
{errorMessage(syncMinute.error)}
+ )} +
+ ) +} diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index e6e496c..4e4f707 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -29,9 +29,11 @@ interface Props { showMarkerToggle?: boolean /** 加监控回调 (传入后信息条显示 RadioTower 图标) */ onMonitor?: () => void - /** 加自选 (传入后信息条显示 Star 图标) */ + /** 自选操作(传入后信息条显示 Star 图标) */ inWatchlist?: boolean - onToggleWatchlist?: () => void + onAddToWatchlist?: (groupId: string | null) => void + onRemoveFromWatchlist?: () => void + watchlistPending?: boolean /** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */ refetchIntervalMs?: number } @@ -52,7 +54,9 @@ export function StockPanel({ showMarkerToggle = true, onMonitor, inWatchlist, - onToggleWatchlist, + onAddToWatchlist, + onRemoveFromWatchlist, + watchlistPending, refetchIntervalMs, }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) @@ -132,7 +136,9 @@ export function StockPanel({ financialMetrics={financialMetrics} onMonitor={onMonitor} inWatchlist={inWatchlist} - onToggleWatchlist={onToggleWatchlist} + onAddToWatchlist={onAddToWatchlist} + onRemoveFromWatchlist={onRemoveFromWatchlist} + watchlistPending={watchlistPending} />
diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index f49a3ab..874b189 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -1,16 +1,18 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { X, RefreshCw, Clock } from 'lucide-react' +import { X, RefreshCw, Clock, LineChart } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cnSignal } from '@/lib/signals' import { StockPanel, getDefaultRange } from '@/components/StockPanel' +import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart' import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' +import { storage } from '@/lib/storage' interface Props { symbol: string | null @@ -34,6 +36,16 @@ const PRESETS: { label: string; months: number }[] = [ { label: '1年', months: 12 }, ] +type PreviewView = 'daily' | 'intraday' +const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const + +function loadIntradayDays(): number { + const saved = storage.stockPreviewIntradayDays.get(10) + return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number]) + ? saved + : 10 +} + function boardTag(symbol: string): { label: string; color: string } | null { if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' } @@ -42,7 +54,8 @@ function boardTag(symbol: string): { label: string; color: string } | null { } export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { - const [showIntraday, setShowIntraday] = useState(false) + const [view, setView] = useState('daily') + const [intradayDays, setIntradayDays] = useState(loadIntradayDays) const [dateRange, setDateRange] = useState(getDefaultRange) const [showMonitorEditor, setShowMonitorEditor] = useState(false) const qc = useQueryClient() @@ -56,7 +69,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol) const toggleWatchlist = useMutation({ - mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!), + mutationFn: ({ + action, + groupId, + }: { + action: 'add' | 'remove' + groupId?: string | null + }) => action === 'remove' + ? api.watchlistRemove(symbol!) + : api.watchlistAdd(symbol!, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) @@ -73,6 +94,10 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props return () => document.removeEventListener('keydown', handler) }, [symbol, onClose]) + useEffect(() => { + if (symbol) setView('daily') + }, [symbol]) + // 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K, // 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。 // 关闭/切股时清除, 避免无谓刷新。 @@ -94,12 +119,19 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props const handleRefresh = () => { if (!symbol) return - qc.invalidateQueries({ queryKey: ['kline', symbol!] }) - if (showIntraday) { + if (view === 'daily') { + qc.invalidateQueries({ queryKey: ['kline', symbol] }) + } else { + qc.invalidateQueries({ queryKey: ['kline-minute-range', symbol] }) qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] }) } } + const selectIntradayDays = (days: number) => { + setIntradayDays(days) + storage.stockPreviewIntradayDays.set(days) + } + return ( {symbol && ( @@ -123,8 +155,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props className="relative w-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col" > {/* 顶栏 */} -
-
+
+
{(() => { const board = symbol ? boardTag(symbol) : null return board ? ( @@ -133,11 +165,51 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props ) : null })()} - {symbol} - {name && {name}} + {symbol} + {name && {name}}
-
+ +
+ +
+
+ + +
+ +
+ {view === 'daily' ? ( + <> {/* 日期范围快捷 */} {PRESETS.map(p => { const now = new Date() @@ -175,23 +247,31 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))} min={dateRange.start} /> + + ) : ( + <> + 区间 +
+ {INTRADAY_DAY_OPTIONS.map(days => ( + + ))} +
+ + )} - | - - {/* 分时开关 */} - - - | + {/* 刷新 */} - - {/* 关闭 */} -
@@ -253,19 +325,28 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
)} - {/* K 线内容 */} + {/* 图表内容 */}
- { if (!showIntraday) setShowIntraday(true) }} - dateRange={dateRange} - onMonitor={() => setShowMonitorEditor(true)} - inWatchlist={inWatchlist} - onToggleWatchlist={() => toggleWatchlist.mutate()} - refetchIntervalMs={intradayRefetchMs} - /> + {view === 'daily' ? ( + setShowMonitorEditor(true)} + inWatchlist={inWatchlist} + onAddToWatchlist={groupId => toggleWatchlist.mutate({ action: 'add', groupId })} + onRemoveFromWatchlist={() => toggleWatchlist.mutate({ action: 'remove' })} + watchlistPending={toggleWatchlist.isPending} + /> + ) : ( + + )}
{/* 加监控编辑器弹层 */} diff --git a/frontend/src/components/WatchlistAddMenu.tsx b/frontend/src/components/WatchlistAddMenu.tsx new file mode 100644 index 0000000..bea247d --- /dev/null +++ b/frontend/src/components/WatchlistAddMenu.tsx @@ -0,0 +1,268 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useQuery } from '@tanstack/react-query' +import { Check, Folder, Inbox, List, LoaderCircle, RefreshCw } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' + +const MENU_WIDTH = 224 +const MENU_MAX_HEIGHT = 320 +const VIEWPORT_GAP = 8 +const TRIGGER_GAP = 6 + +export interface WatchlistGroupMenuProps { + children: ReactNode + onSelect: (groupId: string | null) => void + disabled?: boolean + preferredGroupId?: string | null + includeAll?: boolean + counts?: Record + total?: number + disableEmpty?: boolean + menuLabel?: string + align?: 'left' | 'right' + triggerClassName?: string + title?: string + ariaLabel?: string +} + +/** + * 自选分组选择菜单。 + * 分组仅在菜单打开时读取,React Query 会在多个入口间共享同一份缓存。 + */ +export function WatchlistGroupMenu({ + children, + onSelect, + disabled = false, + preferredGroupId, + includeAll = false, + counts, + total = 0, + disableEmpty = false, + menuLabel = '选择自选分组', + align = 'right', + triggerClassName = '', + title = '加入自选', + ariaLabel = title, +}: WatchlistGroupMenuProps) { + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ top: 0, left: 0 }) + const triggerRef = useRef(null) + const menuRef = useRef(null) + const menuId = useId() + + const groupsQuery = useQuery({ + queryKey: QK.watchlistGroups, + queryFn: api.watchlistGroups, + enabled: open, + staleTime: 60_000, + }) + const groups = groupsQuery.data?.groups ?? [] + const showPreferred = preferredGroupId !== undefined + + const placeMenu = () => { + const trigger = triggerRef.current + if (!trigger) return + + const rect = trigger.getBoundingClientRect() + const menuHeight = Math.min(menuRef.current?.offsetHeight ?? MENU_MAX_HEIGHT, MENU_MAX_HEIGHT) + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + const dropUp = spaceBelow < menuHeight + TRIGGER_GAP && spaceAbove > spaceBelow + const top = dropUp + ? Math.max(VIEWPORT_GAP, rect.top - menuHeight - TRIGGER_GAP) + : Math.min(rect.bottom + TRIGGER_GAP, window.innerHeight - menuHeight - VIEWPORT_GAP) + const rawLeft = align === 'left' ? rect.left : rect.right - MENU_WIDTH + const left = Math.max( + VIEWPORT_GAP, + Math.min(rawLeft, window.innerWidth - MENU_WIDTH - VIEWPORT_GAP), + ) + setPosition({ top, left }) + } + + const toggleMenu = () => { + if (disabled) return + if (open) { + setOpen(false) + return + } + placeMenu() + setOpen(true) + } + + useLayoutEffect(() => { + if (!open) return + placeMenu() + }, [open, groups.length, groupsQuery.isPending]) + + useEffect(() => { + if (!open) return + + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target as Node + if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) + } + const closeOnViewportChange = () => setOpen(false) + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + setOpen(false) + triggerRef.current?.focus() + } + + document.addEventListener('mousedown', closeOnOutsideClick) + window.addEventListener('keydown', closeOnEscape, true) + window.addEventListener('scroll', closeOnViewportChange, true) + window.addEventListener('resize', closeOnViewportChange) + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick) + window.removeEventListener('keydown', closeOnEscape, true) + window.removeEventListener('scroll', closeOnViewportChange, true) + window.removeEventListener('resize', closeOnViewportChange) + } + }, [open]) + + useEffect(() => { + if (!open || groupsQuery.isPending) return + menuRef.current?.querySelector('[role="menuitem"]:not(:disabled)')?.focus() + }, [open, groups.length, groupsQuery.isPending]) + + const choose = (groupId: string | null) => { + setOpen(false) + onSelect(groupId) + } + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return + const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? []) + if (items.length === 0) return + + event.preventDefault() + const current = items.indexOf(document.activeElement as HTMLButtonElement) + if (event.key === 'Home') items[0].focus() + else if (event.key === 'End') items[items.length - 1].focus() + else if (event.key === 'ArrowDown') items[(current + 1 + items.length) % items.length].focus() + else items[(current - 1 + items.length) % items.length].focus() + } + + const menuItemClass = 'flex h-8 w-full items-center gap-2 rounded-btn px-2 text-left text-xs text-secondary outline-none transition-colors hover:bg-elevated hover:text-foreground focus:bg-elevated focus:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-secondary' + const showCounts = counts !== undefined + const ungroupedCount = counts?.ungrouped ?? 0 + + return ( + <> + + + {open && createPortal( + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7b6f8d9..3f68bcd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -200,6 +200,12 @@ export interface MinuteKlineRow { amount: number } +export interface MinuteKlineSession { + date: string + prev_close: number | null + rows: MinuteKlineRow[] +} + export interface PriceLimitInfo { rate: number limit_up: number | null @@ -233,6 +239,27 @@ export interface WatchlistEntry { added_at: string note?: string name?: string | null + group_id?: string | null +} + +export type WatchlistGroupColor = + | 'sky' + | 'blue' + | 'indigo' + | 'violet' + | 'fuchsia' + | 'rose' + | 'orange' + | 'amber' + | 'lime' + | 'emerald' + | 'teal' + | 'cyan' + +export interface WatchlistGroup { + id: string + name: string + color: WatchlistGroupColor } export interface WatchlistImportCandidate { @@ -1398,9 +1425,21 @@ export const api = { source?: 'local' | 'live' | 'none' asset_type?: 'stock' | 'etf' | 'index' price_limit?: PriceLimitInfo | null + prev_close?: number | null }>( `/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`, ), + klineMinuteRange: (symbol: string, days = 10) => + request<{ + symbol: string + name?: string + asset_type: 'stock' | 'etf' | 'index' + requested_days: number + sessions: MinuteKlineSession[] + source: 'local' | 'none' + }>( + `/api/kline/minute-range?symbol=${encodeURIComponent(symbol)}&days=${days}`, + ), indexList: () => request<{ results: IndexInstrument[]; count: number }>('/api/index/list'), indexSearch: (q: string, limit = 20) => request<{ results: IndexInstrument[] }>( @@ -1446,10 +1485,10 @@ export const api = { method: 'POST', body: JSON.stringify({ ...(days ? { days } : {}), ...(extend ? { extend: true } : {}) }), }), - syncMinuteSingle: (symbol: string) => + syncMinuteSingle: (symbol: string, days?: number) => request<{ status: string; symbol: string; rows: number }>('/api/kline/sync_minute_single', { method: 'POST', - body: JSON.stringify({ symbol }), + body: JSON.stringify({ symbol, ...(days != null ? { days } : {}) }), }), clearMinute: () => request<{ status: string; removed: number }>('/api/kline/clear_minute', { @@ -1472,16 +1511,38 @@ export const api = { }), watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'), - watchlistAdd: (symbol: string, note = '') => + watchlistAdd: (symbol: string, note = '', groupId?: string | null) => request<{ symbols: WatchlistEntry[] }>('/api/watchlist', { method: 'POST', - body: JSON.stringify({ symbol, note }), + body: JSON.stringify({ symbol, note, group_id: groupId ?? null }), }), - watchlistBatchAdd: (symbols: string[], note = '') => + watchlistBatchAdd: (symbols: string[], note = '', groupId?: string | null) => request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', { method: 'POST', - body: JSON.stringify({ symbols, note }), + body: JSON.stringify({ symbols, note, group_id: groupId ?? null }), }), + watchlistGroups: () => + request<{ groups: WatchlistGroup[] }>('/api/watchlist/groups'), + watchlistGroupCreate: (name: string, color: WatchlistGroupColor) => + request<{ groups: WatchlistGroup[]; group: WatchlistGroup }>('/api/watchlist/groups', { + method: 'POST', + body: JSON.stringify({ name, color }), + }), + watchlistGroupRename: (groupId: string, name: string, color: WatchlistGroupColor) => + request<{ groups: WatchlistGroup[] }>( + `/api/watchlist/groups/${encodeURIComponent(groupId)}`, + { method: 'PUT', body: JSON.stringify({ name, color }) }, + ), + watchlistGroupDelete: (groupId: string) => + request<{ groups: WatchlistGroup[]; symbols: WatchlistEntry[] }>( + `/api/watchlist/groups/${encodeURIComponent(groupId)}`, + { method: 'DELETE' }, + ), + watchlistSetGroup: (symbol: string, groupId: string | null) => + request<{ symbols: WatchlistEntry[] }>( + `/api/watchlist/${encodeURIComponent(symbol)}/group`, + { method: 'PUT', body: JSON.stringify({ group_id: groupId }) }, + ), watchlistOcrStatus: () => request<{ provider: string; available: boolean }>('/api/watchlist/ocr-status'), watchlistImportImage: (file: File, signal?: AbortSignal, quiet = false) => { diff --git a/frontend/src/lib/intraday-chart.ts b/frontend/src/lib/intraday-chart.ts new file mode 100644 index 0000000..928b6fd --- /dev/null +++ b/frontend/src/lib/intraday-chart.ts @@ -0,0 +1,40 @@ +import type { MinuteKlineRow } from '@/lib/api' + +export function formatMinuteTime(datetime: string): string { + const match = datetime.match(/(\d{2}):(\d{2})/) + if (!match) return datetime.slice(11, 16) + const hour = (parseInt(match[1]) + 8) % 24 + return `${String(hour).padStart(2, '0')}:${match[2]}` +} + +export function computeIntradayAverage(data: MinuteKlineRow[]): number[] { + const result: number[] = [] + let amount = 0 + let volume = 0 + for (const row of data) { + amount += row.amount + volume += row.volume * 100 + result.push(volume > 0 ? amount / volume : row.close) + } + return result +} + +function generateFullDayTimes(): string[] { + const times: string[] = [] + for (let hour = 9; hour <= 11; hour++) { + const startMinute = hour === 9 ? 30 : 0 + const endMinute = hour === 11 ? 30 : 59 + for (let minute = startMinute; minute <= endMinute; minute++) { + times.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`) + } + } + for (let hour = 13; hour <= 15; hour++) { + const endMinute = hour === 15 ? 0 : 59 + for (let minute = 0; minute <= endMinute; minute++) { + times.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`) + } + } + return times +} + +export const FULL_DAY_TIMES = generateFullDayTimes() diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 50f2e75..27489ac 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -23,6 +23,7 @@ export const QK = { // Watchlist watchlist: ['watchlist'] as const, + watchlistGroups: ['watchlist-groups'] as const, watchlistQuotes: ['watchlist-quotes'] as const, watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const, watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const, @@ -61,6 +62,8 @@ export const QK = { stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const, klineMinute: (symbol: string, date: string) => ['kline-minute', symbol, date] as const, + klineMinuteRange: (symbol: string, days: number) => + ['kline-minute-range', symbol, days] as const, indexDaily: (symbol: string, start: string, end: string) => ['index-daily', symbol, start, end] as const, indexMinute: (symbol: string, date: string) => diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 2746d04..1c14271 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -36,6 +36,9 @@ export const storage = { /** 个股日K成交量对比设置 */ stockVolumeCompare: kv<{ enabled: boolean; days: number }>('stock_volume_compare'), + /** 个股详情多日分时周期 */ + stockPreviewIntradayDays: kv('stock_preview_intraday_days'), + /** 策略结果列表列配置 */ screenerResultColumns: kv('screener_result_columns'), diff --git a/frontend/src/lib/useSharedMutations.ts b/frontend/src/lib/useSharedMutations.ts index 0937cba..f825824 100644 --- a/frontend/src/lib/useSharedMutations.ts +++ b/frontend/src/lib/useSharedMutations.ts @@ -29,11 +29,17 @@ export function useUpdateQuoteInterval() { }) } -/** 批量添加自选 — Screener / Intraday / 截图导入 共用 */ +interface WatchlistBatchAddInput { + symbols: string[] + groupId?: string | null +} + +/** 批量添加自选 — Screener / 截图导入共用 */ export function useWatchlistBatchAdd() { const qc = useQueryClient() return useMutation({ - mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols), + mutationFn: ({ symbols, groupId }: WatchlistBatchAddInput) => + api.watchlistBatchAdd(symbols, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) // 前缀匹配: 实际 key 为 ['watchlist-enriched', extColumnsParam], diff --git a/frontend/src/lib/watchlist-group-colors.ts b/frontend/src/lib/watchlist-group-colors.ts new file mode 100644 index 0000000..8cde1f7 --- /dev/null +++ b/frontend/src/lib/watchlist-group-colors.ts @@ -0,0 +1,33 @@ +import type { WatchlistGroupColor } from '@/lib/api' + +export interface WatchlistGroupColorOption { + id: WatchlistGroupColor + label: string + text: string + border: string + background: string + dot: string + ring: string +} + +export const DEFAULT_WATCHLIST_GROUP_COLOR: WatchlistGroupColor = 'sky' + +export const WATCHLIST_GROUP_COLORS: readonly WatchlistGroupColorOption[] = [ + { id: 'sky', label: '天蓝', text: 'text-sky-400', border: 'border-sky-400/40', background: 'bg-sky-400/10', dot: 'bg-sky-400', ring: 'ring-sky-400/60' }, + { id: 'blue', label: '蓝色', text: 'text-blue-400', border: 'border-blue-400/40', background: 'bg-blue-400/10', dot: 'bg-blue-400', ring: 'ring-blue-400/60' }, + { id: 'indigo', label: '靛蓝', text: 'text-indigo-400', border: 'border-indigo-400/40', background: 'bg-indigo-400/10', dot: 'bg-indigo-400', ring: 'ring-indigo-400/60' }, + { id: 'violet', label: '紫色', text: 'text-violet-400', border: 'border-violet-400/40', background: 'bg-violet-400/10', dot: 'bg-violet-400', ring: 'ring-violet-400/60' }, + { id: 'fuchsia', label: '品红', text: 'text-fuchsia-400', border: 'border-fuchsia-400/40', background: 'bg-fuchsia-400/10', dot: 'bg-fuchsia-400', ring: 'ring-fuchsia-400/60' }, + { id: 'rose', label: '玫红', text: 'text-rose-400', border: 'border-rose-400/40', background: 'bg-rose-400/10', dot: 'bg-rose-400', ring: 'ring-rose-400/60' }, + { id: 'orange', label: '橙色', text: 'text-orange-400', border: 'border-orange-400/40', background: 'bg-orange-400/10', dot: 'bg-orange-400', ring: 'ring-orange-400/60' }, + { id: 'amber', label: '金色', text: 'text-amber-400', border: 'border-amber-400/40', background: 'bg-amber-400/10', dot: 'bg-amber-400', ring: 'ring-amber-400/60' }, + { id: 'lime', label: '青柠', text: 'text-lime-400', border: 'border-lime-400/40', background: 'bg-lime-400/10', dot: 'bg-lime-400', ring: 'ring-lime-400/60' }, + { id: 'emerald', label: '绿色', text: 'text-emerald-400', border: 'border-emerald-400/40', background: 'bg-emerald-400/10', dot: 'bg-emerald-400', ring: 'ring-emerald-400/60' }, + { id: 'teal', label: '墨绿', text: 'text-teal-400', border: 'border-teal-400/40', background: 'bg-teal-400/10', dot: 'bg-teal-400', ring: 'ring-teal-400/60' }, + { id: 'cyan', label: '青色', text: 'text-cyan-400', border: 'border-cyan-400/40', background: 'bg-cyan-400/10', dot: 'bg-cyan-400', ring: 'ring-cyan-400/60' }, +] + +export function resolveWatchlistGroupColor(color?: string | null): WatchlistGroupColorOption { + return WATCHLIST_GROUP_COLORS.find(option => option.id === color) + ?? WATCHLIST_GROUP_COLORS[0] +} diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index c195a63..b6b533f 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -14,6 +14,7 @@ import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { DatePicker } from '@/components/DatePicker' import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { useStrategyPool } from '@/lib/useStrategyPool' import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard' import { ScreenerTable } from '@/components/screener/ScreenerTable' @@ -509,8 +510,17 @@ export function Screener() { // 单只股票加入/移出自选 const toggleWatchlist = useMutation({ - mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) => - inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol), + mutationFn: ({ + symbol, + action, + groupId, + }: { + symbol: string + action: 'add' | 'remove' + groupId?: string | null + }) => action === 'remove' + ? api.watchlistRemove(symbol) + : api.watchlistAdd(symbol, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) @@ -567,10 +577,10 @@ export function Screener() { } } - const handleBatchAdd = () => { + const handleBatchAdd = (groupId: string | null) => { if (!displayRows.length) return const symbols = displayRows.map((r: any) => r.symbol) - batchAdd.mutate(symbols, { + batchAdd.mutate({ symbols, groupId }, { onSuccess: (data) => { setBatchMsg(`已添加 ${data.added} 只到自选`) setTimeout(() => setBatchMsg(''), 3000) @@ -815,16 +825,19 @@ export function Screener() {
)} {displayRows.length > 0 && ( - + )} - + {inWatchlist ? ( + + ) : ( + onAdd(r.symbol, groupId)} + preferredGroupId={preferredGroupId} + disabled={addPending} + triggerClassName="shrink-0 rounded p-1 text-muted transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50" + > + + + )}
) })} @@ -402,6 +420,9 @@ const StockCard = React.memo(function StockCard({ onToggleExpand, onDimensionClick, isMonitored, + groups, + onGroupChange, + groupChangePending, }: { r: any candleRows: KlineRow[] @@ -416,6 +437,9 @@ const StockCard = React.memo(function StockCard({ onToggleExpand: (key: string) => void onDimensionClick: (target: DimensionMembersTarget) => void isMonitored?: boolean + groups: WatchlistGroup[] + onGroupChange: (symbol: string, groupId: string | null) => void + groupChangePending: boolean }) { const board = boardTag(r.symbol) const price = r.rt_price ?? r.close @@ -444,7 +468,7 @@ const StockCard = React.memo(function StockCard({ {/* 左侧彩色指示条 */}
- {/* 删除按钮 / 确认区 */} + {/* 分组与删除入口 */}
{isConfirming ? (
e.stopPropagation()}> @@ -459,20 +483,29 @@ const StockCard = React.memo(function StockCard({
) : ( - +
e.stopPropagation()}> + + +
)}
{/* 卡片内容 */}
{/* 第一行: 代码 + 名称 + 板块标识 */} -
+
{r.symbol} @@ -584,6 +617,7 @@ export function Watchlist() { const [columns, setColumns] = useState([...BUILTIN_COLUMNS]) const [customizerOpen, setCustomizerOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) + const [selectedGroup, setSelectedGroup] = useState('all') const [ocrAvailable, setOcrAvailable] = useState(null) const [ocrInstallHint, setOcrInstallHint] = useState('') const columnsLoaded = useRef(false) @@ -696,6 +730,26 @@ export function Watchlist() { queryFn: api.watchlistList, }) + const groupList = useQuery({ + queryKey: QK.watchlistGroups, + queryFn: api.watchlistGroups, + }) + const groups = groupList.data?.groups ?? [] + const activeGroupId = selectedGroup === 'all' || selectedGroup === 'ungrouped' + ? null + : selectedGroup + + useEffect(() => { + if ( + selectedGroup !== 'all' + && selectedGroup !== 'ungrouped' + && groupList.isSuccess + && !groups.some(group => group.id === selectedGroup) + ) { + setSelectedGroup('all') + } + }, [groupList.isSuccess, groups, selectedGroup]) + // enriched 数据 — 传入 ext_columns 参数 const enriched = useQuery({ queryKey: QK.watchlistEnriched(extColumnsParam), @@ -743,7 +797,8 @@ export function Watchlist() { const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {} const addMutation = useMutation({ - mutationFn: (sym: string) => api.watchlistAdd(sym), + mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) => + api.watchlistAdd(symbol, '', groupId), onSuccess: (data) => { qc.setQueryData(QK.watchlist, data) qc.invalidateQueries({ queryKey: QK.watchlist }) @@ -791,6 +846,36 @@ export function Watchlist() { }, }) + const createGroup = useMutation({ + mutationFn: ({ name, color }: { name: string; color: WatchlistGroupColor }) => + api.watchlistGroupCreate(name, color), + onSuccess: data => { + qc.setQueryData(QK.watchlistGroups, { groups: data.groups }) + setSelectedGroup(data.group.id) + }, + }) + + const renameGroup = useMutation({ + mutationFn: ({ groupId, name, color }: { groupId: string; name: string; color: WatchlistGroupColor }) => + api.watchlistGroupRename(groupId, name, color), + onSuccess: data => qc.setQueryData(QK.watchlistGroups, data), + }) + + const deleteGroup = useMutation({ + mutationFn: (groupId: string) => api.watchlistGroupDelete(groupId), + onSuccess: (data, groupId) => { + qc.setQueryData(QK.watchlistGroups, { groups: data.groups }) + qc.setQueryData(QK.watchlist, { symbols: data.symbols }) + if (selectedGroup === groupId) setSelectedGroup('all') + }, + }) + + const assignGroup = useMutation({ + mutationFn: ({ symbol, groupId }: { symbol: string; groupId: string | null }) => + api.watchlistSetGroup(symbol, groupId), + onSuccess: data => qc.setQueryData(QK.watchlist, data), + }) + // 二次确认状态 const [confirmClear, setConfirmClear] = useState(false) const [confirmRemove, setConfirmRemove] = useState(null) @@ -804,9 +889,35 @@ export function Watchlist() { }, [remove]) const handleCardCancelRemove = useCallback(() => setConfirmRemove(null), []) const handleCardRequestRemove = useCallback((sym: string) => setConfirmRemove(sym), []) + const handleGroupChange = useCallback((symbol: string, groupId: string | null) => { + assignGroup.mutate({ symbol, groupId }) + }, [assignGroup]) - const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? [] + const listEntries = list.data?.symbols ?? [] + const allSymbols = listEntries.map(s => s.symbol) const rows = enriched.data?.rows ?? [] + const groupBySymbol = useMemo( + () => new Map(listEntries.map(entry => [entry.symbol, entry.group_id ?? null])), + [listEntries], + ) + const groupCounts = useMemo(() => { + const counts: Record = { ungrouped: 0 } + for (const entry of listEntries) { + const groupId = entry.group_id ?? 'ungrouped' + counts[groupId] = (counts[groupId] ?? 0) + 1 + } + return counts + }, [listEntries]) + const rowsInSelectedGroup = useMemo(() => { + const rowsWithGroup = rows.map(row => ({ ...row, group_id: groupBySymbol.get(row.symbol) ?? null })) + if (selectedGroup === 'all') return rowsWithGroup + if (selectedGroup === 'ungrouped') return rowsWithGroup.filter(row => row.group_id == null) + return rowsWithGroup.filter(row => row.group_id === selectedGroup) + }, [groupBySymbol, rows, selectedGroup]) + const activeGroup = activeGroupId + ? groups.find(group => group.id === activeGroupId) + : undefined + const watchlistContentLoading = list.isLoading || (allSymbols.length > 0 && enriched.isLoading) // 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示; // Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。 @@ -885,7 +996,7 @@ export function Watchlist() { // 筛选 + 排序 const filteredRows = useMemo(() => { // 板块筛选(全选时跳过) - let result = rows + let result = rowsInSelectedGroup if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) { result = result.filter(r => { // 非股票 (指数/ETF) 无板块语义, 不受板块筛选影响 (顺带修复 ETF 行被误过滤) @@ -914,7 +1025,7 @@ export function Watchlist() { }) } return result - }, [rows, filters, columns, boardFilter]) + }, [rowsInSelectedGroup, filters, columns, boardFilter]) const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length const hasBoardFilter = boardFilter.size > 0 && boardFilter.size < BOARDS.length @@ -961,8 +1072,8 @@ export function Watchlist() { ) // "被筛选条件隐藏" 的个股数: 后端返回的行数 vs 经过前端筛选后的行数. - // rows.length 是后端实际返回 (含 pending 行), 减去 sortedRows (筛选后) 才是真正的筛选隐藏. - const hiddenCount = Math.max(0, rows.length - sortedRows.length) + // 分组切换不计入筛选隐藏,只比较当前分组内的数据。 + const hiddenCount = Math.max(0, rowsInSelectedGroup.length - sortedRows.length) const renderStockCard = (r: any) => ( ) @@ -993,7 +1107,7 @@ export function Watchlist() { {sortedRows.length} / - {allSymbols.length} + {rowsInSelectedGroup.length} {/* 数据未就绪提示: 自选了但 enriched 缓存未覆盖 (新股/冷门/新用户未同步), 指标全为 null */} @@ -1045,7 +1159,9 @@ export function Watchlist() { { setPreviewSymbol(sym); setPreviewName(name) }} existingSymbols={allSymbols as string[]} - onAdd={(sym) => addMutation.mutate(sym)} + onAdd={(symbol, groupId) => addMutation.mutate({ symbol, groupId })} + preferredGroupId={activeGroupId} + addPending={addMutation.isPending} />
) } diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 58f966f..8f1d4b0 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -29,6 +29,7 @@ import { StrategyNavChart } from './charts/StrategyNavChart' import { ReturnDistributionChart } from './charts/ReturnDistributionChart' import { TradeKlineModal } from './components/TradeKlineModal' import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions' +import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu' const formatDate = (date: Date) => date.toISOString().slice(0, 10) const monthsAgo = (months: number) => { @@ -774,6 +775,15 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri queryFn: () => api.watchlistList(), staleTime: 30_000, }) + const watchlistEntries = watchlist.data?.symbols ?? [] + const watchlistCounts = useMemo(() => { + const counts: Record = { ungrouped: 0 } + for (const entry of watchlistEntries) { + const groupId = entry.group_id ?? 'ungrouped' + counts[groupId] = (counts[groupId] ?? 0) + 1 + } + return counts + }, [watchlistEntries]) useEffect(() => { if (results.length === 0) return @@ -802,9 +812,11 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri setOpen(false) } const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol)) - // 一键导入自选: 合并去重, 顺带回填股票名 - const importFromWatchlist = () => { - const entries = watchlist.data?.symbols ?? [] + // 按分组导入自选: 合并去重, 顺带回填股票名 + const importFromWatchlist = (groupId: string | null) => { + const entries = groupId === 'all' + ? watchlistEntries + : watchlistEntries.filter(entry => (entry.group_id ?? null) === groupId) if (entries.length === 0) return setSymbolNames(prev => { const next = { ...prev } @@ -813,7 +825,7 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri }) setSymbols([...symbols, ...entries.map(e => e.symbol)]) } - const watchlistCount = watchlist.data?.symbols?.length ?? 0 + const watchlistCount = watchlistEntries.length return (
@@ -861,16 +873,22 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri {symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`} - +