feat: add watchlist groups and multi-day intraday view

This commit is contained in:
shy3130
2026-08-10 11:39:08 +08:00
parent f16609a1db
commit 052d4cb18b
25 changed files with 2433 additions and 257 deletions
+127 -2
View File
@@ -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():
+65 -9
View File
@@ -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)
+240 -51
View File
@@ -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 []
+107
View File
@@ -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},
))
+114
View File
@@ -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"},
]
+12 -1
View File
@@ -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" },