mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
- 后端: watchlist 条目 group_id 单值列迁移为 group_ids 列表列, 首次
写入前自动备份 .bak; 新增 add_to_group/remove_from_group 及
POST/DELETE /groups/{gid}/members/{symbol} 端点
- 前端: 分组选择器改为勾选式弹出面板, 触发器显示多色点(最多3+N);
移除语义收窄为仅移出当前分组, 移出全部后提示标的仍在自选
- 适配: 分组页签计数/筛选、等权涨跌幅、分组卡片、监控自选导入、
回测/挖掘两页分组导入均按 group_ids 归属计入每个所属分组
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""自选分组持久化与 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_ids"] == []
|
|
|
|
|
|
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_ids"] == [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_ids"] == [] 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_ids"] == [created["id"]]
|
|
rows = watchlist.set_group("600000.SH", None)
|
|
assert rows[0]["group_ids"] == []
|
|
|
|
|
|
def test_reorder_groups(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, first = watchlist.create_group("一")
|
|
_, second = watchlist.create_group("二")
|
|
_, third = watchlist.create_group("三")
|
|
|
|
reordered = watchlist.reorder_groups([third["id"], first["id"], second["id"]])
|
|
assert [group["name"] for group in reordered] == ["三", "一", "二"]
|
|
assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"]
|
|
|
|
# ids 与现有分组不一致 (缺失 / 多余 / 重复) 均拒绝
|
|
with pytest.raises(ValueError, match="不一致"):
|
|
watchlist.reorder_groups([first["id"], second["id"]])
|
|
with pytest.raises(ValueError, match="不一致"):
|
|
watchlist.reorder_groups([first["id"], second["id"], third["id"], "missing"])
|
|
with pytest.raises(ValueError, match="不一致"):
|
|
watchlist.reorder_groups([first["id"], first["id"], second["id"], third["id"]])
|
|
# 失败请求不改变现有顺序
|
|
assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"]
|
|
|
|
|
|
def test_reorder_groups_api(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, first = watchlist.create_group("一")
|
|
_, second = watchlist.create_group("二")
|
|
|
|
result = watchlist_api.reorder_groups(
|
|
watchlist_api.GroupReorderRequest(ordered_ids=[second["id"], first["id"]])
|
|
)
|
|
assert [group["name"] for group in result["groups"]] == ["二", "一"]
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
watchlist_api.reorder_groups(
|
|
watchlist_api.GroupReorderRequest(ordered_ids=["missing"])
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
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_ids"] == [group_id]
|
|
|
|
moved = watchlist_api.assign_group(
|
|
"600000.SH",
|
|
watchlist_api.GroupAssignRequest(group_id=None),
|
|
request,
|
|
)
|
|
assert moved["symbols"][0]["group_ids"] == []
|
|
|
|
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"},
|
|
]
|
|
|
|
|
|
def test_clear_group_moves_members_to_ungrouped(monkeypatch, tmp_path):
|
|
"""清空分组:成员变未分组,分组定义保留。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, group = watchlist.create_group("芯片")
|
|
watchlist.add("600000.SH", group_id=group["id"])
|
|
watchlist.add("000001.SZ", group_id=group["id"])
|
|
watchlist.add("300750.SZ") # 不在任何分组
|
|
|
|
rows = watchlist.clear_group(group["id"])
|
|
# 3 只都还在,group_id 全部为 None
|
|
assert len(rows) == 3
|
|
assert all(r["group_ids"] == [] for r in rows)
|
|
# 分组定义仍在
|
|
assert any(g["id"] == group["id"] for g in watchlist.list_groups())
|
|
|
|
# 清空不存在的分组 → KeyError
|
|
with pytest.raises(KeyError):
|
|
watchlist.clear_group("missing")
|
|
|
|
|
|
def test_clear_group_api(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"]
|
|
watchlist.add("600000.SH", group_id=group_id)
|
|
watchlist.add("000001.SZ", group_id=group_id)
|
|
|
|
result = watchlist_api.clear_group(group_id, request)
|
|
assert all(s["group_ids"] == [] for s in result["symbols"])
|
|
|
|
# 不存在的分组 → 404
|
|
with pytest.raises(HTTPException) as exc:
|
|
watchlist_api.clear_group("missing", request)
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
# ── 多组成员关系 (M:N) ──────────────────────────────────────
|
|
|
|
|
|
def test_multi_group_membership(monkeypatch, tmp_path):
|
|
"""一股可同时属于多个分组; 移出一个不影响其他。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, ga = watchlist.create_group("半导体")
|
|
_, gb = watchlist.create_group("反包备选")
|
|
watchlist.add("600118.SH", group_id=ga["id"])
|
|
|
|
rows = watchlist.add_to_group("600118.SH", gb["id"])
|
|
got = next(r for r in rows if r["symbol"] == "600118.SH")
|
|
assert got["group_ids"] == [ga["id"], gb["id"]] # 两组并存
|
|
|
|
# 重复加入幂等
|
|
rows = watchlist.add_to_group("600118.SH", gb["id"])
|
|
got = next(r for r in rows if r["symbol"] == "600118.SH")
|
|
assert got["group_ids"] == [ga["id"], gb["id"]]
|
|
|
|
# 移出一个, 另一个保留
|
|
rows = watchlist.remove_from_group("600118.SH", ga["id"])
|
|
got = next(r for r in rows if r["symbol"] == "600118.SH")
|
|
assert got["group_ids"] == [gb["id"]]
|
|
|
|
# 移出最后一个 → 未分组(仍在自选)
|
|
rows = watchlist.remove_from_group("600118.SH", gb["id"])
|
|
got = next(r for r in rows if r["symbol"] == "600118.SH")
|
|
assert got["group_ids"] == []
|
|
assert any(r["symbol"] == "600118.SH" for r in rows)
|
|
|
|
# 不存在的分组/标的
|
|
with pytest.raises(ValueError, match="不存在"):
|
|
watchlist.add_to_group("600118.SH", "missing")
|
|
with pytest.raises(KeyError):
|
|
watchlist.remove_from_group("000001.SZ", ga["id"])
|
|
|
|
|
|
def test_set_group_exclusive_keeps_only_one(monkeypatch, tmp_path):
|
|
"""互斥设定: 已在多组的标的被 set 后只保留指定组。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, ga = watchlist.create_group("一")
|
|
_, gb = watchlist.create_group("二")
|
|
watchlist.add("600000.SH", group_id=ga["id"])
|
|
watchlist.add_to_group("600000.SH", gb["id"])
|
|
|
|
rows = watchlist.set_group("600000.SH", ga["id"])
|
|
assert rows[0]["group_ids"] == [ga["id"]]
|
|
|
|
|
|
def test_clear_group_strips_only_that_group(monkeypatch, tmp_path):
|
|
"""清空分组只摘该组标签, 其他组成员关系保留。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
_, ga = watchlist.create_group("一")
|
|
_, gb = watchlist.create_group("二")
|
|
watchlist.add("600000.SH", group_id=ga["id"])
|
|
watchlist.add_to_group("600000.SH", gb["id"])
|
|
watchlist.add("000001.SZ", group_id=ga["id"])
|
|
|
|
rows = watchlist.clear_group(ga["id"])
|
|
a = next(r for r in rows if r["symbol"] == "600000.SH")
|
|
b = next(r for r in rows if r["symbol"] == "000001.SZ")
|
|
assert a["group_ids"] == [gb["id"]] # 二组保留
|
|
assert b["group_ids"] == []
|
|
|
|
|
|
def test_legacy_single_group_id_migration(monkeypatch, tmp_path):
|
|
"""旧 schema(单值 group_id 列)读取迁移 + 首次写回前自动备份 .bak。"""
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
path = tmp_path / "user_data" / "watchlist.parquet"
|
|
path.parent.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": ["600000.SH", "000001.SZ"],
|
|
"added_at": ["2026-08-08T10:00:00"] * 2,
|
|
"note": ["", ""],
|
|
"group_id": ["g1", None],
|
|
}).write_parquet(path)
|
|
|
|
rows = watchlist.list_symbols()
|
|
assert rows[0]["group_ids"] == ["g1"]
|
|
assert rows[1]["group_ids"] == []
|
|
assert not (tmp_path / "user_data" / "watchlist.parquet.bak").exists() # 只读不备份
|
|
|
|
# 触发写入 → 备份生成, 文件落新 schema
|
|
watchlist.add("300750.SZ")
|
|
assert (tmp_path / "user_data" / "watchlist.parquet.bak").exists()
|
|
df = pl.read_parquet(path)
|
|
assert "group_ids" in df.columns and "group_id" not in df.columns
|