Files
tick-stock-panel/backend/app/services/watchlist.py
T
richardandClaude e9f5c606b6 feat(watchlist): batch import CSV / pasted codes into groups
自选页新增批量导入:支持 CSV/TXT 文件与粘贴证券代码两种来源,解析出
候选并在弹窗内确认后按 M:N 分组一次性写入(目标分组可多选或就地新建,
默认只勾新增标的,已在自选的可并入所选分组)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 14:48:32 +08:00

444 lines
15 KiB
Python

"""自选股与分组服务。
自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的
``watchlist_groups.json``。
成员关系为多值 (M:N): 每条自选带 ``group_ids: list[str]``, 同一标的可同时
属于多个分组; 移出分组只摘标签(标的仍在自选), 移出自选才删除实体。
旧 schema (单值 ``group_id`` 列) 读取时自动迁移为 ``[group_id]``, 首次写回
新 schema 前留一份 ``watchlist.parquet.bak`` 备份。
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
from datetime import datetime
from pathlib import Path
import polars as pl
from app.config import settings
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
from app.tickflow.rate_limits import chunked, resolve_limit
logger = logging.getLogger(__name__)
_LOCK = threading.RLock()
# 数据版本号: 每次写盘 +1 (在 _LOCK 内递增, 读取免锁)。供监控引擎等进程内
# 消费方做缓存失效判断 —— 版本没变就不必重读文件, 版本一变立即拿到新成员。
_REVISION = 0
def revision() -> int:
"""自选/分组数据版本号, 每次写操作递增。"""
return _REVISION
_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_ids": pl.List(pl.Utf8),
}
def _path() -> Path:
p = settings.data_dir / "user_data" / "watchlist.parquet"
p.parent.mkdir(parents=True, exist_ok=True)
return p
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 _empty_entries()
df = pl.read_parquet(p)
# 旧 schema 兼容: 单值 group_id → group_ids=[gid]; 两列都缺 → 空列表
if "group_ids" not in df.columns:
old = df["group_id"].to_list() if "group_id" in df.columns else [None] * df.height
df = df.with_columns(
pl.Series("group_ids", [[g] if g else [] for g in old], dtype=pl.List(pl.Utf8))
).drop("group_id", strict=False)
if "symbol" not in df.columns:
df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("symbol"))
if "added_at" not in df.columns:
df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("added_at"))
if "note" not in df.columns:
df = df.with_columns(pl.lit(None, dtype=pl.Utf8).alias("note"))
return df.select(list(_ENTRY_SCHEMA))
def _write_entries(df: pl.DataFrame) -> None:
global _REVISION
p = _path()
# 首次从旧 schema 迁移到 group_ids 前, 备份原文件(一次性)
if p.exists():
try:
if "group_ids" not in pl.read_parquet_schema(p).names():
shutil.copy(p, p.with_suffix(p.suffix + ".bak"))
except OSError as e:
logger.warning("watchlist backup before migration failed: %s", e)
tmp = p.with_suffix(p.suffix + ".tmp")
df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp)
os.replace(tmp, p)
_REVISION += 1
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:
global _REVISION
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)
_REVISION += 1
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,
group_ids: list[str] | None = None,
) -> tuple[list[dict], int]:
"""批量添加并保持既有语义:每个新处理的标的移动到列表最前面。
分组为可选的初始分组:``group_id`` 单组(如从某分组页添加)或 ``group_ids``
多组(如批量导入同时并入多个分组)。重复添加的标的保留既有全部分组,
仅把尚未属于的传入分组并入;二者可同时使用、内部去重。
"""
with _LOCK:
groups = _read_groups()
# 合并单/多组参数并去重;逐组校验存在性
apply_ids: list[str] = []
for gid in (group_ids or []) + ([group_id] if group_id is not None else []):
if gid in apply_ids:
continue
_validate_group_id(gid, groups)
apply_ids.append(gid)
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]
gids = list((existing or {}).get("group_ids") or [])
for gid in apply_ids:
if gid not in gids:
gids.append(gid)
rows.insert(0, {
"symbol": symbol,
"added_at": datetime.utcnow().isoformat(timespec="seconds"),
"note": note,
"group_ids": gids,
})
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]:
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]:
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:
"""清空自选列表。返回移除的数量。"""
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 reorder_groups(ordered_ids: list[str]) -> list[dict]:
"""按给定 id 顺序重排分组 (json 数组顺序即定义顺序)。"""
with _LOCK:
groups = _read_groups()
by_id = {group["id"]: group for group in groups}
if len(ordered_ids) != len(groups) or set(ordered_ids) != set(by_id):
raise ValueError("分组顺序与现有分组不一致")
reordered = [by_id[group_id] for group_id in ordered_ids]
_write_groups(reordered)
return reordered
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 = _strip_group(_read_entries(), 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]:
"""互斥设定: 该标的只保留这一个分组(group_id=None 即全部移出, 变未分组)。
多组模型的日常操作走 add_to_group / remove_from_group; 本函数服务于
「仅保留此组」的显式场景。
"""
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
rows = _read_entries().to_dicts()
if not any(row["symbol"] == symbol for row in rows):
raise KeyError(symbol)
for row in rows:
if row["symbol"] == symbol:
row["group_ids"] = [group_id] if group_id is not None else []
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
_write_entries(out)
return out.to_dicts()
def add_to_group(symbol: str, group_id: str) -> list[dict]:
"""把标的加入一个分组(多组成员关系: 不影响已属于的其他分组)。"""
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
rows = _read_entries().to_dicts()
if not any(row["symbol"] == symbol for row in rows):
raise KeyError(symbol)
for row in rows:
if row["symbol"] == symbol:
gids = row["group_ids"] or []
if group_id not in gids:
gids.append(group_id)
row["group_ids"] = gids
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
_write_entries(out)
return out.to_dicts()
def remove_from_group(symbol: str, group_id: str) -> list[dict]:
"""把标的移出一个分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。"""
with _LOCK:
groups = _read_groups()
_validate_group_id(group_id, groups)
rows = _read_entries().to_dicts()
if not any(row["symbol"] == symbol for row in rows):
raise KeyError(symbol)
for row in rows:
if row["symbol"] == symbol:
row["group_ids"] = [g for g in (row["group_ids"] or []) if g != group_id]
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
_write_entries(out)
return out.to_dicts()
def _strip_group(df: pl.DataFrame, group_id: str) -> pl.DataFrame:
"""从所有条目的 group_ids 中摘掉指定分组(删除分组/清空分组共用)。"""
rows = df.to_dicts()
for row in rows:
gids = row.get("group_ids") or []
if group_id in gids:
row["group_ids"] = [g for g in gids if g != group_id]
return pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries()
def clear_group(group_id: str) -> list[dict]:
"""清空分组成员:把该分组标签从所有条目摘掉(变未分组),保留分组定义。"""
with _LOCK:
groups = _read_groups()
if not any(group["id"] == group_id for group in groups):
raise KeyError(group_id)
df = _strip_group(_read_entries(), group_id)
_write_entries(df)
return df.to_dicts()
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
"""拉取实时行情。
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
"""
if not symbols:
return []
tf = get_client()
quotes: list[dict] = []
# 走 batch
if capset.has(Cap.QUOTE_BATCH):
batch_size = resolve_limit(capset, Cap.QUOTE_BATCH, default_batch=50).batch
elif capset.has(Cap.QUOTE_BY_SYMBOL):
batch_size = resolve_limit(capset, Cap.QUOTE_BY_SYMBOL, default_batch=5).batch
else:
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
# 提前返回空,避免发起注定失败的请求
return []
chunks = chunked(symbols, batch_size)
# 用线程池为每个批次加超时保护
pool = ThreadPoolExecutor(max_workers=1)
for chunk in chunks:
try:
future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True)
raw = future.result(timeout=timeout_s)
if raw is None or len(raw) == 0:
continue
df = pl.from_pandas(raw)
rename_map = {
"last_price": "price",
"ext.change_pct": "pct",
"ext.name": "name",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
quotes.extend(df.to_dicts())
except FuturesTimeout:
logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk))
break # 超时后不再尝试后续批次
except Exception as e: # noqa: BLE001
logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e)
pool.shutdown(wait=False)
return quotes